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

@@ -34,6 +34,11 @@ public:
// Latest snapshot of every slot (indexed 0..kMaxPads-1).
[[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

View File

@@ -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<int>(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<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

View File

@@ -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<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;
int steam_count_ = 0;
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

View File

@@ -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<PadInfo, kMaxPads> pads_;
};

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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<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();
capture.set_target(game);
audio.set_target(game);