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.
This commit is contained in:
2026-08-07 15:23:10 +02:00
parent 9e9bb9f466
commit 0e40b374e7
2 changed files with 48 additions and 2 deletions
@@ -17,6 +17,11 @@ package io.unom.punktfunk.kit
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path. * reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
*/ */
object DsDevice { object DsDevice {
/** The pads' native acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */
private const val DS_RAW_ACCEL_LSB_PER_G = 8192L
/** The wire's, from `punktfunk_core::input::gamepad::MOTION_ACCEL_LSB_PER_G`. */
private const val WIRE_ACCEL_LSB_PER_G = 10000L
const val VID_SONY = 0x054C const val VID_SONY = 0x054C
const val PID_DUALSENSE = 0x0CE6 const val PID_DUALSENSE = 0x0CE6
const val PID_DUALSENSE_EDGE = 0x0DF2 const val PID_DUALSENSE_EDGE = 0x0DF2
@@ -153,7 +158,7 @@ object DsDevice {
out.buttons = w out.buttons = w
if (len >= 28) { if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i) for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i) for (i in 0 until 3) out.accel[i] = accelToWire(i16(r, 22 + 2 * i))
} }
if (len >= 41) { if (len >= 41) {
unpackTouch(r, 33, out, 0) unpackTouch(r, 33, out, 0)
@@ -189,7 +194,7 @@ object DsDevice {
out.buttons = w out.buttons = w
if (len >= 25) { if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i) for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i) for (i in 0 until 3) out.accel[i] = accelToWire(i16(r, 19 + 2 * i))
} }
if (len >= 43) { if (len >= 43) {
unpackTouch(r, 35, out, 0) unpackTouch(r, 35, out, 0)
@@ -227,6 +232,28 @@ object DsDevice {
private fun i16(r: ByteArray, o: Int): Int = private fun i16(r: ByteArray, o: Int): Int =
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt() ((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
/**
* Raw DualSense/DualShock 4 acceleration → the wire's units.
*
* The pad reports acceleration in its own device units; the wire is fixed at
* `MOTION_ACCEL_LSB_PER_G` = 10000 LSB per g (`punktfunk_core::input::gamepad`). Forwarding the
* raw value verbatim — which this path did until 2026-08-07 — hands the host a number ~18 %
* short, because the pad's native resolution is the 8192 LSB/g that `hid-playstation` calls
* `DS_ACC_RES_PER_G`. Measured on glass: a DualSense flat and face up arrived as 0.811 g where
* 1.000 was owed, against 8192/10000 = 0.819 predicted.
*
* The residual ~1 % is this unit's factory bias, which only its calibration feature report can
* remove — that read is still owed (it also fixes gyro, whose factory calibration is emphatically
* NOT near-identity and so cannot be corrected by a nominal constant like this one).
*
* Clamped because the rescale is a >1 multiplier: a real ±4 g slam near full scale would
* otherwise wrap the i16 and read as an impossible acceleration in the opposite direction.
*/
private fun accelToWire(raw: Int): Int =
((raw.toLong() * WIRE_ACCEL_LSB_PER_G) / DS_RAW_ACCEL_LSB_PER_G)
.coerceIn(-32768L, 32767L)
.toInt()
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of // Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`). // the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
private fun stickX(raw: Int): Int = raw * 257 - 32768 private fun stickX(raw: Int): Int = raw * 257 - 32768
+19
View File
@@ -497,6 +497,25 @@
#define PUNKTFUNK_AXIS_RT 5 #define PUNKTFUNK_AXIS_RT 5
// Motion wire units — the DualSense convention, raw `i16` LSBs, carried by
// `RichInput::Motion`. Gyro is angular velocity, accel is proper acceleration.
//
// Every capture path scales *into* these units (`pf-client-core::gamepad`, Swift
// `GamepadWire`, the Android `DeviceGyro`) and every host backend decodes *from* them —
// but the two sides never meet in one crate, which is how a virtual pad shipped for
// months telling its consumers to read the same bytes 40× too fast. The host's virtual
// pads carry fixed calibration blobs, and the resolution a consumer derives from those
// blobs must land back on exactly these numbers; pf-inject's `motion_contract` test is
// what pins that, for every backend, against these constants.
//
// Gyro saturates at `i16::MAX / 20` ≈ ±1638 °/s, below a real DualSense's ±2000; accel at
// ±3.28 g against its ±4 g. Lifting those is a wire-v2 question, not a scale to quietly
// re-tune here.
#define MOTION_GYRO_LSB_PER_DEG_S 20
// See [`MOTION_GYRO_LSB_PER_DEG_S`].
#define MOTION_ACCEL_LSB_PER_G 10000
// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]). // Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
#define PUNKTFUNK_MAGIC 201 #define PUNKTFUNK_MAGIC 201