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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user