diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index c4a1da0a..c315a96b 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -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 last{-1}; + size_t end = std::min(size, static_cast(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(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(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(size); } diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index 8e617a8d..a5f18a06 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt index 2c63badc..0d26272b 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt @@ -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()) }