diff --git a/README.md b/README.md index 64e5681..fd28b2f 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,6 @@ is removed from this list once done — so the top item is always next. The self-verifiable tooling / UI / input items come first; the game-pipeline items that need a real game (and Remote Play) to fully validate come last. -- **Rumble / haptics forwarding (both backends).** Currently unsupported — the XInput - hook swallows `XInputSetState`. Add a reverse path: the hook captures the game's - `XInputSetState` (left/right motor) and publishes it over a hook→host channel (the - back-channel already exists), and the host drives the guest's actuators per backend — - **XInput:** call `XInputSetState` on the guest's slot (the viability unknown is - whether Steam's RPT virtual pad accepts vibration and routes it to the guest); - **Steam Input:** `SteamInput()->TriggerVibration` / `Legacy_TriggerHapticPulse`. - Map each guest slot to the right actuator. - **Per-backend input debug visualization.** To separate "wrong input *into* the tool" from "wrong input *out to* the game", show three distinct views in the Controllers panel (under Debug details): (a) **received via XInput** (raw diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index c24aa0e..9351173 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -12,7 +12,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // refuses to attach to a host with a mismatched version. -inline constexpr std::uint32_t kProtocolVersion = 8; +inline constexpr std::uint32_t kProtocolVersion = 9; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -127,6 +127,12 @@ struct HookStatus // call count. Lets the Injection panel list exactly what's hooked and how busy. std::uint32_t hook_entry_count; HookEntry hook_entries[kMaxHookEntries]; + + // Per-slot rumble the game last requested via XInputSetState (hook is sole + // writer). The host forwards it to the guest's controller. Plain POD like the + // other diagnostics -- benign cross-process races are fine. + std::uint16_t rumble_left[kMaxPads]; + std::uint16_t rumble_right[kMaxPads]; }; // Host -> hook control channel. The host requests which hook subsystems should be diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 8a7e349..efaa05a 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -140,6 +140,17 @@ public: } } + // Record the rumble the game requested for a slot (so the host can forward it to + // the guest's controller). Plain stores; the hook is the sole writer. + void note_rumble(std::uint32_t slot, std::uint16_t left, std::uint16_t right) + { + if (block_ != nullptr && slot < kMaxPads) + { + block_->status.rumble_left[slot] = left; + block_->status.rumble_right[slot] = right; + } + } + // --- Audio render-hook diagnostics ------------------------------------- // Total distinct render streams the audio hook has observed. diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp index 05849ef..36a6090 100644 --- a/hook/src/xinput_hook.cpp +++ b/hook/src/xinput_hook.cpp @@ -138,16 +138,20 @@ DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_ return ERROR_SUCCESS; } -// Swallow rumble: it would otherwise be sent to whatever physical device sits at -// this index on the host machine. Forwarding it back to the guest is a later -// phase; for now report success so the game's logic is happy. -DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/) +// Don't drive a physical device at this index on the host; instead record the +// requested motor speeds so the host can forward them to the guest's controller. +// Still report success so the game's logic is happy. +DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* vibration) { hook_note_call(g_id_setstate); if (user_index >= kMaxPads || !g_cache[user_index].connected) { return ERROR_DEVICE_NOT_CONNECTED; } + if (g_ipc != nullptr && vibration != nullptr) + { + g_ipc->note_rumble(user_index, vibration->wLeftMotorSpeed, vibration->wRightMotorSpeed); + } return ERROR_SUCCESS; } diff --git a/host/src/input/input_source.hpp b/host/src/input/input_source.hpp index 46638c7..f26228b 100644 --- a/host/src/input/input_source.hpp +++ b/host/src/input/input_source.hpp @@ -34,6 +34,11 @@ public: // Latest snapshot of every slot (indexed 0..kMaxPads-1). [[nodiscard]] virtual const std::array& pads() const = 0; + + // Drive the guest controller's rumble for `slot` (motor speeds 0..65535). The + // game requests this via XInputSetState; the host forwards it here. Default + // no-op; backends that can reach the guest's actuator override it. + virtual void set_rumble(int /*slot*/, std::uint16_t /*left*/, std::uint16_t /*right*/) {} }; } // namespace coop diff --git a/host/src/input/steam_input_source.cpp b/host/src/input/steam_input_source.cpp index 930aa50..508d797 100644 --- a/host/src/input/steam_input_source.cpp +++ b/host/src/input/steam_input_source.cpp @@ -192,14 +192,35 @@ void SteamInputSource::poll() InputHandle_t handles[STEAM_INPUT_MAX_COUNT] = {}; steam_count_ = SteamInput()->GetConnectedControllers(handles); + for (std::uint32_t i = 0; i < kMaxPads; ++i) + { + controllers_[i] = 0; + steam_slot_[i] = false; + } for (int i = 0; i < steam_count_ && i < static_cast(kMaxPads); ++i) { + controllers_[i] = handles[i]; PadInfo steam_pad; if (read_steam_pad(handles[i], steam_pad)) { pads_[i] = steam_pad; // Steam controller active on this slot -> use it + steam_slot_[i] = true; } } } +void SteamInputSource::set_rumble(int slot, std::uint16_t left, std::uint16_t right) +{ + if (slot < 0 || slot >= static_cast(kMaxPads)) + { + return; + } + if (steam_ready_ && steam_slot_[slot] && controllers_[slot] != 0) + { + SteamInput()->TriggerVibration(controllers_[slot], left, right); + return; + } + xinput_.set_rumble(slot, left, right); // slot not Steam-driven -> XInput fallback +} + } // namespace coop diff --git a/host/src/input/steam_input_source.hpp b/host/src/input/steam_input_source.hpp index 17f8318..bc7f474 100644 --- a/host/src/input/steam_input_source.hpp +++ b/host/src/input/steam_input_source.hpp @@ -41,6 +41,10 @@ public: return pads_; } + // Forward rumble to the guest: SteamInput TriggerVibration on the slot's + // controller when it's Steam-active, else the XInput fallback. + void set_rumble(int slot, std::uint16_t left, std::uint16_t right) override; + [[nodiscard]] bool steam_active() const { return steam_ready_; @@ -57,6 +61,11 @@ private: XInputSource xinput_; // fallback for slots Steam Input doesn't fill std::array pads_; + // Per-slot Steam controller handle + whether Steam Input is driving that slot + // (updated each poll), so set_rumble can target the right actuator. + std::uint64_t controllers_[kMaxPads] = {}; + bool steam_slot_[kMaxPads] = {}; + bool steam_ready_ = false; int steam_count_ = 0; const char* name_ = "XInput"; diff --git a/host/src/input/xinput_source.cpp b/host/src/input/xinput_source.cpp index 03ea2ab..7125920 100644 --- a/host/src/input/xinput_source.cpp +++ b/host/src/input/xinput_source.cpp @@ -39,4 +39,14 @@ void XInputSource::poll() } } +void XInputSource::set_rumble(int slot, std::uint16_t left, std::uint16_t right) +{ + if (slot < 0 || slot >= static_cast(kMaxPads)) + { + return; + } + XINPUT_VIBRATION v{left, right}; + XInputSetState(static_cast(slot), &v); +} + } // namespace coop diff --git a/host/src/input/xinput_source.hpp b/host/src/input/xinput_source.hpp index 4a670b7..412bdcd 100644 --- a/host/src/input/xinput_source.hpp +++ b/host/src/input/xinput_source.hpp @@ -22,6 +22,9 @@ public: return pads_; } + // Forward rumble to the XInput device at `slot` (the guest's RPT virtual pad). + void set_rumble(int slot, std::uint16_t left, std::uint16_t right) override; + private: std::array pads_; }; diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 9964717..dc2e7a6 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -85,6 +85,11 @@ HookStatusView IpcServer::hook_status() const { view.hook_entries[i] = s.hook_entries[i]; } + for (std::uint32_t i = 0; i < kMaxPads; ++i) + { + view.rumble_left[i] = s.rumble_left[i]; + view.rumble_right[i] = s.rumble_right[i]; + } return view; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 2bbe230..e659d65 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -36,6 +36,10 @@ struct HookStatusView // Installed-hooks registry (for the Injection panel's hook list). std::uint32_t hook_entry_count = 0; HookEntry hook_entries[kMaxHookEntries] = {}; + + // Per-slot rumble the game requested (host forwards it to the guest's pad). + std::uint16_t rumble_left[kMaxPads] = {}; + std::uint16_t rumble_right[kMaxPads] = {}; }; // Plain snapshot of the Present-hook video channel for the Video mirror panel. diff --git a/host/src/main.cpp b/host/src/main.cpp index b820923..a983853 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -117,6 +117,10 @@ int run() coop::UiState ui; coop::FrameStats stats; + // Last rumble forwarded per slot, so we only re-send on change. + std::uint16_t last_rumble_l[coop::kMaxPads] = {}; + std::uint16_t last_rumble_r[coop::kMaxPads] = {}; + while (window.pump_messages()) { #ifdef COOP_WITH_STEAM @@ -148,6 +152,22 @@ int run() injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel injection.publish(input->pads()); injection.tick(); // refresh target liveness before the mirror panels read game_hwnd() + + // Forward the rumble the game requested back to the guest's controller (only + // when it changes, to avoid spamming XInputSetState / TriggerVibration). + { + const coop::HookStatusView hs = injection.hook_status(); + for (int i = 0; i < static_cast(coop::kMaxPads); ++i) + { + if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i]) + { + input->set_rumble(i, hs.rumble_left[i], hs.rumble_right[i]); + last_rumble_l[i] = hs.rumble_left[i]; + last_rumble_r[i] = hs.rumble_right[i]; + } + } + } + const HWND game = injection.game_hwnd(); capture.set_target(game); audio.set_target(game); diff --git a/tests/hook_selftest.cpp b/tests/hook_selftest.cpp index 589cb9e..86f7367 100644 --- a/tests/hook_selftest.cpp +++ b/tests/hook_selftest.cpp @@ -170,6 +170,11 @@ int main() check(block->status.get_state_calls[1].load(std::memory_order_relaxed) >= 1, "status counts slot 1 GetState"); check(block->status.get_caps_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetCaps"); + // Rumble forwarding: exercise_dll called XInputSetState(0, {0x8000, 0x4000}); the + // hook should have recorded it into the status for the host to forward to the guest. + check(block->status.rumble_left[0] == 0x8000 && block->status.rumble_right[0] == 0x4000, + "status records rumble from XInputSetState"); + hook::remove_xinput_hooks(); std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);