Read the phone's motion sensors only while Steam asks for them (#534)

perf: only read the phone's motion sensors when Steam wants them

The gyro and accelerometer ran at 4 ms for the whole session, even in games without gyro, costing about 6% of a core. Steam already tells the Deck pad when it wants motion (setting 48), so libfakeinput now passes that on and PadMotion follows it, with a short delay to ride out Steam's brief toggles.
This commit is contained in:
Muaz Kadan authored and GitHub committed 2026-10-10 14:44:05 -04:00
1 parent 0579e688ba
commit c115916962
3 files changed
+112 -12

No files matched your search

+40 -1
View File
@@ -1246,7 +1246,7 @@ struct DeckHidraw {
// Absent from a ring file written without one.
struct DeckImu {
uint32_t magic; // IMU1 once written
uint32_t reserved;
uint32_t imu_mode; // written by this library: the client's SETTING_IMU_MODE | 0x80000000
uint64_t seq; // odd while being written
int16_t accel[3];
int16_t gyro[3];
@@ -1906,6 +1906,41 @@ __attribute__((visibility("hidden"))) static void log_deck_feature(const char *w
if ((n & (n - 1)) == 0) Logger::log("deck: feature 0x%02x %s (x%u)\n", feature, what, n);
}
// A settings report (ID_SET_SETTINGS_VALUES) is a list of (setting id, 16-bit value) triples after
// the length byte. Returns SETTING_IMU_MODE (48, a SETTING_GYRO_MODE_* bitmask from SDL's
// controller_constants.h) when the report changes it, logged, else -1: the client repeats its
// settings for as long as it has the pad.
static constexpr uint8_t DECK_SET_SETTINGS_VALUES = 0x87;
static constexpr uint8_t DECK_SETTING_IMU_MODE = 48;
__attribute__((visibility("hidden"))) static int deck_imu_mode_change(const uint8_t *buf, size_t size) {
static std::atomic<int32_t> last{-1};
size_t end = std::min(size, static_cast<size_t>(3) + buf[2]);
for (size_t i = 3; i + 3 <= end; i += 3) {
if (buf[i] != DECK_SETTING_IMU_MODE) continue;
int32_t value = buf[i + 1] | buf[i + 2] << 8;
if (last.exchange(value) == value) return -1;
Logger::log("deck: imu mode 0x%04x\n", value);
return value;
}
return -1;
}
// Tells the app whether the client wants motion (DeckImu::imu_mode, FakeInputWriter.readImuMode):
// PadMotion reads the phone's sensors only while it does. The mapping is read-only, and the
// client changes the mode a few times a minute at most, so the file is written directly.
__attribute__((visibility("hidden"))) static void publish_imu_mode(const DeckHidraw &deck, int mode) {
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
if (deck.mapping_size < FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE) return;
std::string path = get_ring_path_for_slot(0);
int fd = path.empty() ? -1 : my_open(path.c_str(), O_WRONLY | O_CLOEXEC);
if (fd < 0) return;
uint32_t value = 0x80000000u | static_cast<uint32_t>(mode);
if (pwrite(fd, &value, sizeof(value), FAKE_INPUT_RING_SIZE + offsetof(DeckImu, imu_mode)) != sizeof(value))
Logger::log("deck: imu mode not published (%s)\n", strerror(errno));
syscall(SYS_close, fd);
}
__attribute__((visibility("hidden"))) static int
deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
size_t size = _IOC_SIZE(op);
@@ -1919,6 +1954,10 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8;
send_vibration(left, right, left || right ? 1000 : 0, 0);
}
if (buf[1] == DECK_SET_SETTINGS_VALUES && size >= 3) {
int imu_mode = deck_imu_mode_change(buf, size);
if (imu_mode >= 0) publish_imu_mode(deck, imu_mode);
}
log_deck_feature("set", buf[1]);
return static_cast<int>(size);
}
@@ -99,6 +99,8 @@ public class FakeInputWriter {
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
private static final int IMU_MODE_OFFSET = IMU_OFFSET + 4;
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
@@ -215,6 +217,7 @@ public class FakeInputWriter {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putInt(IMU_MODE_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
@@ -476,6 +479,23 @@ public class FakeInputWriter {
}
}
/**
* The motion the Steam client asked the Deck controller of slot {@code slot} for: its
* SETTING_IMU_MODE (0 = off), or -1 until it has said. Safe from any thread.
*/
public static int readImuMode(int slot) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return -1;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
int value = ring == null ? 0 : ring.getInt(IMU_MODE_OFFSET);
return value < 0 ? value & 0xffff : -1;
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
@@ -20,9 +20,10 @@ import kotlin.math.roundToInt
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
* Sensors run only while the session is on screen with the pad a Deck controller, and the client
* wants motion (see [follow]), sampled as often as the Deck reports (4 ms); each reading goes
* straight into the pad's ring ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report
* picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
@@ -32,6 +33,8 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
private var reading = false
private var idlePolls = 0
fun start() {
if (thread != null || sensors == null) return
@@ -45,8 +48,13 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
handler.post(object : Runnable {
override fun run() {
if (!active) return
follow(handler, gyroscope, accelerometer)
handler.postDelayed(this, POLL_MS)
}
})
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
@@ -54,17 +62,48 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
// On the worker, after any poll or reading already queued there, so nothing registers again
// or overwrites the still controller.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
sensors?.unregisterListener(this)
reading = false
idlePolls = 0
still()
}
worker.quitSafely()
}
/**
* Reads the sensors while the client wants motion: the Deck's IMU mode (unknown counts as
* wanted), or Steam's touch controls asking for gyro. A game whose layout has no gyro turns the
* mode off; Steam's own menus keep it on. The client also turns it off and on again for a few
* ms whenever it re-applies a layout, so the sensors stop only after two polls without it.
*/
private fun follow(handler: Handler, gyroscope: Sensor?, accelerometer: Sensor?) {
val touch = SteamTouchDevice.current
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
idlePolls = if (wanted) 0 else idlePolls + 1
if (wanted && !reading) {
reading = true
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: reading")
} else if (reading && idlePolls >= 2) {
reading = false
sensors?.unregisterListener(this)
still()
Log.i(TAG, "pad motion: paused, the client wants none")
}
}
/** A still controller, not one frozen mid-turn. */
private fun still() {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
@@ -99,6 +138,8 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
/** How often [follow] checks whether the client wants motion. */
private const val POLL_MS = 500L
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}