Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 65 additions & 21 deletions packages/core/src/drivers/playstation-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
};

Expand Down
67 changes: 67 additions & 0 deletions packages/core/test/imu-probe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading