Forward rumble back to the guest controller (both backends)

The XInput hook used to swallow XInputSetState; now it records the requested
left/right motor speeds into the status back-channel (protocol v8->v9: per-slot
rumble_left/right in HookStatus). Each frame the host reads them and, only on
change, drives the guest's actuator via the active backend:
- XInput: XInputSetState on the guest's slot (the open question is whether Steam's
  RPT virtual pad accepts vibration and routes it to the guest -- needs live RPT);
- Steam Input: SteamInput TriggerVibration on the slot's controller handle, with the
  XInput fallback for slots Steam isn't driving.

InputSource gains a set_rumble(slot,left,right) hook (default no-op) implemented by
both backends; SteamInputSource now tracks per-slot controller handles + which slots
it drives.

Verified: hook_selftest (x64 + x86) now asserts the hook records the rumble from
XInputSetState into the status; full build x64 + x86 clean; ctest x64 9/9, x86 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 05:11:33 +02:00
parent 7673f186db
commit 056a478e19
13 changed files with 108 additions and 13 deletions

View File

@@ -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 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. 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 - **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 tool" from "wrong input *out to* the game", show three distinct views in the
Controllers panel (under Debug details): (a) **received via XInput** (raw Controllers panel (under Debug details): (a) **received via XInput** (raw

View File

@@ -12,7 +12,7 @@ namespace coop
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version. // 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. // 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; 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. // call count. Lets the Injection panel list exactly what's hooked and how busy.
std::uint32_t hook_entry_count; std::uint32_t hook_entry_count;
HookEntry hook_entries[kMaxHookEntries]; 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 // Host -> hook control channel. The host requests which hook subsystems should be

View File

@@ -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 ------------------------------------- // --- Audio render-hook diagnostics -------------------------------------
// Total distinct render streams the audio hook has observed. // Total distinct render streams the audio hook has observed.

View File

@@ -138,16 +138,20 @@ DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_
return ERROR_SUCCESS; return ERROR_SUCCESS;
} }
// Swallow rumble: it would otherwise be sent to whatever physical device sits at // Don't drive a physical device at this index on the host; instead record the
// this index on the host machine. Forwarding it back to the guest is a later // requested motor speeds so the host can forward them to the guest's controller.
// phase; for now report success so the game's logic is happy. // Still report success so the game's logic is happy.
DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/) DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* vibration)
{ {
hook_note_call(g_id_setstate); hook_note_call(g_id_setstate);
if (user_index >= kMaxPads || !g_cache[user_index].connected) if (user_index >= kMaxPads || !g_cache[user_index].connected)
{ {
return ERROR_DEVICE_NOT_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; return ERROR_SUCCESS;
} }

View File

@@ -34,6 +34,11 @@ public:
// Latest snapshot of every slot (indexed 0..kMaxPads-1). // Latest snapshot of every slot (indexed 0..kMaxPads-1).
[[nodiscard]] virtual const std::array<PadInfo, kMaxPads>& pads() const = 0; [[nodiscard]] virtual const std::array<PadInfo, kMaxPads>& 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 } // namespace coop

View File

@@ -192,14 +192,35 @@ void SteamInputSource::poll()
InputHandle_t handles[STEAM_INPUT_MAX_COUNT] = {}; InputHandle_t handles[STEAM_INPUT_MAX_COUNT] = {};
steam_count_ = SteamInput()->GetConnectedControllers(handles); 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<int>(kMaxPads); ++i) for (int i = 0; i < steam_count_ && i < static_cast<int>(kMaxPads); ++i)
{ {
controllers_[i] = handles[i];
PadInfo steam_pad; PadInfo steam_pad;
if (read_steam_pad(handles[i], steam_pad)) if (read_steam_pad(handles[i], steam_pad))
{ {
pads_[i] = steam_pad; // Steam controller active on this slot -> use it 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<int>(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 } // namespace coop

View File

@@ -41,6 +41,10 @@ public:
return pads_; 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 [[nodiscard]] bool steam_active() const
{ {
return steam_ready_; return steam_ready_;
@@ -57,6 +61,11 @@ private:
XInputSource xinput_; // fallback for slots Steam Input doesn't fill XInputSource xinput_; // fallback for slots Steam Input doesn't fill
std::array<PadInfo, kMaxPads> pads_; std::array<PadInfo, kMaxPads> 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; bool steam_ready_ = false;
int steam_count_ = 0; int steam_count_ = 0;
const char* name_ = "XInput"; const char* name_ = "XInput";

View File

@@ -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<int>(kMaxPads))
{
return;
}
XINPUT_VIBRATION v{left, right};
XInputSetState(static_cast<DWORD>(slot), &v);
}
} // namespace coop } // namespace coop

View File

@@ -22,6 +22,9 @@ public:
return pads_; 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: private:
std::array<PadInfo, kMaxPads> pads_; std::array<PadInfo, kMaxPads> pads_;
}; };

View File

@@ -85,6 +85,11 @@ HookStatusView IpcServer::hook_status() const
{ {
view.hook_entries[i] = s.hook_entries[i]; 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; return view;
} }

View File

@@ -36,6 +36,10 @@ struct HookStatusView
// Installed-hooks registry (for the Injection panel's hook list). // Installed-hooks registry (for the Injection panel's hook list).
std::uint32_t hook_entry_count = 0; std::uint32_t hook_entry_count = 0;
HookEntry hook_entries[kMaxHookEntries] = {}; 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. // Plain snapshot of the Present-hook video channel for the Video mirror panel.

View File

@@ -117,6 +117,10 @@ int run()
coop::UiState ui; coop::UiState ui;
coop::FrameStats stats; 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()) while (window.pump_messages())
{ {
#ifdef COOP_WITH_STEAM #ifdef COOP_WITH_STEAM
@@ -148,6 +152,22 @@ int run()
injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel
injection.publish(input->pads()); injection.publish(input->pads());
injection.tick(); // refresh target liveness before the mirror panels read game_hwnd() 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<int>(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(); const HWND game = injection.game_hwnd();
capture.set_target(game); capture.set_target(game);
audio.set_target(game); audio.set_target(game);

View File

@@ -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_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"); 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(); hook::remove_xinput_hooks();
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures); std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);