From 0f371422164646422780c5176bdbd073c676c6fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 09:46:14 +0000 Subject: [PATCH] PlayStation BT IMU: guard against accel-as-gyro offset shift (macOS side-to-side) (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pass at the macOS BT DualSense "side to side" fusion bug. The IMU-offset probe already runs on Bluetooth (the docstring claiming USB-only was stale); the gap was the selection in init(), which trusted the documented offset whenever its at-rest ACCEL looked ~1g — without checking gyro. A byte-shifted offset (e.g. macOS/IOKit including the leading report-ID byte in the BT 0x31 DataView) reads an accel axis AS a gyro axis, so its at-rest "gyro" sits near gravity (~8192). Fusion integrates that as rotation → the model swings side to side, exactly the in-game symptom. - Extract the selection into a testable `_chooseImuOffset(probe)` and add an accel-as-gyro guard: reject the default when its at-rest |gyro| is pinned near gravity, and recover the cleanest candidate (~1g accel AND near-zero gyro). The high-gyro gate means a small real bias / a handled pad never trips it, so the existing off-by-2 still-pad protection is preserved. - Add raw-bytes + length diagnostic logging on the probe so a macOS BT 0x31 capture (the key unknown) is one console line away. - Fix the stale "USB only" probe docstring. - Tests: default-kept, macOS accel-as-gyro recovery, off-by-2 still-pad protection, and motion fallback (89 pass). Hardware-verification pending: needs a real macOS + DualSense BT capture to confirm the exact byte mechanism (length/report-ID-byte) and that the fix lands the correct orientation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017NiS2a4jZ877XgftkH1Dd1 --- .../core/src/drivers/playstation-driver.js | 86 ++++++++++++++----- packages/core/test/imu-probe.test.js | 67 +++++++++++++++ 2 files changed, 132 insertions(+), 21 deletions(-) diff --git a/packages/core/src/drivers/playstation-driver.js b/packages/core/src/drivers/playstation-driver.js index d12653a..57899d6 100644 --- a/packages/core/src/drivers/playstation-driver.js +++ b/packages/core/src/drivers/playstation-driver.js @@ -62,24 +62,58 @@ export class PlayStationDriver extends ControllerDriver { // back to the mode/transport default offset. const probe = await this._probeImuOffset(); if (probe) { - // At-rest accel magnitude is ambiguous to ±2 bytes: gravity sits on a - // single axis that's shared between an offset and its neighbour-by-2, so - // both score ~1g. If the pad isn't perfectly still during the probe, the - // gyro tie-breaker can flip to the wrong (off-by-2) neighbour — which - // reads an accel axis as gyro and makes steering jerk hard side to side. - // So: trust the DOCUMENTED default offset whenever it scored plausibly - // (~1g); only let the probe override when the default is clearly wrong - // (a clone with a genuinely different layout). - const defaultOffset = this._defaultGyroOffset(); - const defaultScore = probe.scores?.find((s) => s.gyroOffset === defaultOffset); - const plausible = (s) => s && s.meanAccelMag > 6500 && s.meanAccelMag < 11500; - const chosen = plausible(defaultScore) ? defaultScore : probe; + const chosen = this._chooseImuOffset(probe); this._detectedImuOffset = chosen; this._detectedImuFamily = PlayStationDriver._imuFamilyFor(chosen.gyroOffset, chosen.baseOffset); - console.log(`PlayStation IMU offset=${chosen.gyroOffset} (default=${defaultOffset}, probe-best=${probe.gyroOffset}, accelMag≈${chosen.meanAccelMag.toFixed(0)}) → family='${this._detectedImuFamily}' (${this.entry?.name || 'unknown'})`); + const defaultOffset = this._defaultGyroOffset(); + console.log(`PlayStation IMU offset=${chosen.gyroOffset} (default=${defaultOffset}, probe-best=${probe.gyroOffset}, accelMag≈${chosen.meanAccelMag.toFixed(0)}, gyroAbs≈${chosen.meanGyroAbs?.toFixed(0)}) → family='${this._detectedImuFamily}' (${this.entry?.name || 'unknown'})`); } } + /** + * Pick the IMU gyro offset from a probe result. + * + * Prefer the DOCUMENTED default offset when it looks right: at-rest accel + * magnitude ≈ 1g AND an at-rest gyro that ISN'T pinned near gravity. That + * second guard is the new part (issue #83): an offset that is shifted by a + * byte — e.g. macOS/IOKit including the leading report-ID byte in the BT + * `0x31` DataView — reads an accelerometer axis *as a gyro axis*, so its + * "gyro" sits around gravity (~8192 raw) at rest. Fusion then integrates + * gravity as rotation and the model swings hard side to side (the reported + * symptom). The old check only looked at accel magnitude, so such a shifted + * default could still score "plausible" and win. + * + * Only when the default is implausible OR is reading accel-as-gyro do we look + * for a cleaner candidate (≈1g accel AND near-zero gyro). The high-gyro gate + * means a small real at-rest bias — or a pad that's merely being handled + * during the probe — never trips the override, so the existing off-by-2 + * still-pad protection is preserved. + */ + _chooseImuOffset(probe) { + const ACCEL_MIN = 6500, ACCEL_MAX = 11500; // ~1g (8192 raw) window + const ACCEL_AS_GYRO = 4000; // at-rest |gyro| this large ⇒ reading accel as gyro + const CLEAN_GYRO = 2000; // at-rest |gyro| below this ⇒ a trustworthy axis + const accelOK = (s) => s && s.meanAccelMag > ACCEL_MIN && s.meanAccelMag < ACCEL_MAX; + + const defaultOffset = this._defaultGyroOffset(); + const def = probe.scores?.find((s) => s.gyroOffset === defaultOffset); + + // Default is trustworthy: right magnitude AND not bleeding gravity into gyro. + if (accelOK(def) && def.meanGyroAbs < ACCEL_AS_GYRO) return def; + + // Default is implausible or accel-bleeding-into-gyro (the macOS BT + // report-ID-byte shift). Recover the cleanest candidate that has BOTH ≈1g + // accel and near-zero gyro. + const clean = (probe.scores || []) + .filter((s) => accelOK(s) && s.meanGyroAbs < CLEAN_GYRO) + .sort((a, b) => a.score - b.score)[0]; + if (clean) return clean; + + // Nothing clean (e.g. the pad was in motion the whole probe) — keep the + // documented default if it at least read ≈1g, else the raw probe best. + return accelOK(def) ? def : probe; + } + /** * Bring a Bluetooth DualShock 4 / DualSense out of compatibility mode * (short report 0x01, no IMU) into full-report mode (DS4 0x11 / DS5 0x31, @@ -203,16 +237,17 @@ export class PlayStationDriver extends ControllerDriver { /** * One-shot IMU layout probe. Listens to inputreport for up to `timeoutMs`, * collecting up to 10 reports, then scores each candidate gyro offset by - * how close the at-rest accel magnitude is to 8192 (1g). Returns the - * winner, or null if no usable reports arrived. + * how close the at-rest accel magnitude is to 8192 (1g) with gyro near zero. + * Returns the winner (plus the full `scores` table), or null if no usable + * reports arrived. * - * Only runs on USB report 0x01 today. BT branch differs (offset shifts - * with the leading counter byte) and the offset relationship is the same - * up to that constant, so the same probe-then-add-baseOffset logic could - * extend to BT later — punted for now since the immediate case is USB. + * Runs over USB (`0x01`) AND Bluetooth (DS5 `0x31` / DS4 `0x11`) — see + * `_probeConfig()` for the per-transport report id, base offset (BT prepends + * a counter byte), and candidate gyro offsets. `init()` → `_chooseImuOffset()` + * turns the scores into the offset parseReport uses. * - * @param {number} [timeoutMs=500] - * @returns {Promise<{gyroOffset:number, accelOffset:number, meanAccelMag:number}|null>} + * @param {number} [timeoutMs=600] + * @returns {Promise<{gyroOffset:number, accelOffset:number, baseOffset:number, meanAccelMag:number, meanGyroAbs:number, score:number, scores:Array}|null>} */ // Per-transport probe configuration: which input report carries the IMU, // the byte its payload starts at (BT prepends counter/header bytes — DS5 @@ -266,6 +301,15 @@ export class PlayStationDriver extends ControllerDriver { // Diagnostic: full candidate table (report id + length + per-offset). console.log(`PlayStation IMU probe scan (report 0x${wantId.toString(16)}, ${reports[0].byteLength}B):`, scores.map(s => `g=${s.gyroOffset} accel≈${s.meanAccelMag.toFixed(0)} gyro≈${s.meanGyroAbs.toFixed(0)}`).join(' | ')); + // Raw-bytes preview of the first report — the capture needed to confirm + // a platform offset shift (issue #83: macOS BT report-ID-byte). Logs the + // leading header/IMU window so USB-vs-macOS-BT byteLength + layout can be + // diffed straight from the console. + const d0 = reports[0]; + const hexPreview = Array.from( + new Uint8Array(d0.buffer, d0.byteOffset, Math.min(d0.byteLength, 32)) + ).map((b) => b.toString(16).padStart(2, '0')).join(' '); + console.log(`PlayStation IMU probe bytes (${this.connectionType} 0x${wantId.toString(16)}, len=${d0.byteLength}, baseOffset=${baseOffset}): ${hexPreview}…`); resolve({ ...scores[0], scores }); }; diff --git a/packages/core/test/imu-probe.test.js b/packages/core/test/imu-probe.test.js index 9875725..442c8c2 100644 --- a/packages/core/test/imu-probe.test.js +++ b/packages/core/test/imu-probe.test.js @@ -104,6 +104,73 @@ test('init keeps the documented BT default (14) when the probe ties to the off-b assert.equal(d._detectedImuOffset.gyroOffset, 14); }); +// ── _chooseImuOffset: default-vs-recover selection (issue #83) ── +// The selection runs on the probe's score table. A score is +// { gyroOffset, baseOffset, meanAccelMag, meanGyroAbs, score }. + +/** Minimal driver with a fixed transport/mode for exercising _chooseImuOffset. */ +function chooser(connectionType, mode) { + return new PlayStationDriver(new FakeHidDevice([]), connectionType, { mode }); +} +/** Build a probe-shaped result from raw score rows (auto-fills score + best). */ +function probeFrom(rows) { + const scores = rows.map((r) => ({ + baseOffset: r.baseOffset ?? 0, + score: Math.abs(r.meanAccelMag - 8192) + r.meanGyroAbs, + ...r, + })); + const best = [...scores].sort((a, b) => a.score - b.score)[0]; + return { ...best, scores }; +} + +test('chooseImuOffset keeps the documented default when it reads ~1g with low gyro', () => { + const d = chooser('bluetooth', 'ds5'); // default BT DS5 = 16 + const chosen = d._chooseImuOffset(probeFrom([ + { gyroOffset: 16, baseOffset: 1, meanAccelMag: 8192, meanGyroAbs: 40 }, + { gyroOffset: 15, baseOffset: 1, meanAccelMag: 31000, meanGyroAbs: 9000 }, + { gyroOffset: 17, baseOffset: 1, meanAccelMag: 33, meanGyroAbs: 12000 }, + ])); + assert.equal(chosen.gyroOffset, 16); +}); + +test('chooseImuOffset recovers the shifted offset when the default reads accel-as-gyro (macOS BT, side-to-side)', () => { + // The #83 signature: the documented default (16) shows ~1g accel BUT its + // at-rest gyro sits near gravity (~8192) because a leading-byte shift makes + // it read an accel axis as a gyro axis. The true offset (17, shifted +1) is + // clean: ~1g accel, gyro ~0. Old logic (accel-only) wrongly kept 16. + const d = chooser('bluetooth', 'ds5'); // default BT DS5 = 16 + const chosen = d._chooseImuOffset(probeFrom([ + { gyroOffset: 16, baseOffset: 1, meanAccelMag: 8200, meanGyroAbs: 8190 }, // default: accel-as-gyro + { gyroOffset: 17, baseOffset: 1, meanAccelMag: 8192, meanGyroAbs: 25 }, // true (shifted +1): clean + { gyroOffset: 15, baseOffset: 1, meanAccelMag: 22000, meanGyroAbs: 14000 }, + ])); + assert.equal(chosen.gyroOffset, 17, 'should recover the clean +1-shifted offset'); +}); + +test('chooseImuOffset does NOT override on a small real bias (off-by-2 still-pad protection)', () => { + // Default 14 has a small real bias (gyro 100), neighbour 16 reads accelX (0) + // as gyro so its gyro is even lower — but 14 is correct. The high-gyro gate + // means the small bias never trips the override, so 14 is kept. + const d = chooser('bluetooth', 'ds4'); // default BT DS4 = 14 + const chosen = d._chooseImuOffset(probeFrom([ + { gyroOffset: 14, baseOffset: 2, meanAccelMag: 8192, meanGyroAbs: 100 }, + { gyroOffset: 16, baseOffset: 2, meanAccelMag: 8192, meanGyroAbs: 0 }, + ])); + assert.equal(chosen.gyroOffset, 14); +}); + +test('chooseImuOffset falls back to the documented default when nothing is clean (pad in motion)', () => { + // Every offset has high gyro (pad moving throughout the probe) — no clean + // candidate exists, so keep the documented default that at least read ~1g. + const d = chooser('bluetooth', 'ds5'); // default 16 + const chosen = d._chooseImuOffset(probeFrom([ + { gyroOffset: 16, baseOffset: 1, meanAccelMag: 8300, meanGyroAbs: 6000 }, + { gyroOffset: 15, baseOffset: 1, meanAccelMag: 9000, meanGyroAbs: 7000 }, + { gyroOffset: 17, baseOffset: 1, meanAccelMag: 7000, meanGyroAbs: 9000 }, + ])); + assert.equal(chosen.gyroOffset, 16); +}); + test('probe returns null over Bluetooth when no reports arrive', async () => { const d = new PlayStationDriver(new FakeHidDevice([]), 'bluetooth', { mode: 'ds4' }); assert.equal(await d._probeImuOffset(50), null);