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