Fix input forwarding: default to XInput, make Steam Input opt-in
Making Steam Input the default backend silently broke input forwarding. Merely initializing Steam Input activates Steam's in-process XInput interception, which hides controllers from XInputGetState unless they're bound to our action set for the running appid. With no such binding (the normal case for a donor appid) Steam Input reports zero controllers AND XInput now sees nothing -> no input at all. Reproduced with coop_steam_input_probe: without Steam, XInput slot 0 is seen; with Steam Input initialized, 0 Steam controllers and the XInput fallback goes empty. Default to XInput (RPT delivers guest pads there and it works) and make Steam Input an opt-in Controllers-panel toggle that switches the backend at runtime; turning it off restores XInput. All 6 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
24
README.md
24
README.md
@@ -19,7 +19,7 @@ XInput game becomes Remote-Play-Together-able.
|
|||||||
|
|
||||||
| Concern | Mechanism | Component | Status |
|
| Concern | Mechanism | Component | Status |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Receive guest input | Steam Input (primary, when built with the Steamworks SDK) with XInput fallback; RPT delivers guest pads to the focused window | `coop_host.exe` | done |
|
| Receive guest input | XInput (RPT delivers guest pads to the focused window); optional, opt-in Steam Input when built with the Steamworks SDK | `coop_host.exe` | done |
|
||||||
| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | done |
|
| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | done |
|
||||||
| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` | done |
|
| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` | done |
|
||||||
| Mirror video | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | done |
|
| Mirror video | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | done |
|
||||||
@@ -158,17 +158,19 @@ Done:
|
|||||||
all subsystems hooked, and the IPC channels (status, audio ring, video share,
|
all subsystems hooked, and the IPC channels (status, audio ring, video share,
|
||||||
log) all flow across the x64↔x86 boundary.
|
log) all flow across the x64↔x86 boundary.
|
||||||
|
|
||||||
- **Steam Input. ✅** When the host is built with the Steamworks SDK
|
- **Steam Input (opt-in). ✅** When the host is built with the Steamworks SDK
|
||||||
(auto-detected under `third_party/steamworks_sdk/`), guest input comes through
|
(auto-detected under `third_party/steamworks_sdk/`), a **Use Steam Input**
|
||||||
the Steam Input API (action-based) as the primary path, falling back to XInput
|
checkbox in the Controllers panel switches the input backend to the Steam Input
|
||||||
per slot — and to pure XInput if Steam isn't available, so the host always runs.
|
API (action-based) at runtime; it loads a bundled action manifest
|
||||||
It initializes SteamAPI + Steam Input and loads a bundled action manifest
|
|
||||||
(`steam_input_actions.vdf`) via `SetInputActionManifestFilePath`, so it needs no
|
(`steam_input_actions.vdf`) via `SetInputActionManifestFilePath`, so it needs no
|
||||||
partner-backend config. Verified to initialize against the live Steam client and
|
partner-backend config. **It's off by default and XInput is the primary path**:
|
||||||
enumerate controllers (`coop_steam_input_probe`). Reading actual button/stick
|
merely initializing Steam Input activates Steam's in-process XInput interception,
|
||||||
state still requires a controller bound through Steam Input for the running
|
which *hides* controllers from XInput unless they're bound to our action set for
|
||||||
appid (the donor appid under RPT) — without that binding the XInput path carries
|
the running appid — so defaulting to it silently broke input forwarding. Enabling
|
||||||
the guest input, as before.
|
it is only useful once a controller is bound to Steam Input for the donor appid;
|
||||||
|
otherwise leave it off and the proven XInput path carries the guest input.
|
||||||
|
Verified to initialize and enumerate controllers against the live Steam client
|
||||||
|
(`coop_steam_input_probe`), and that toggling it off restores XInput.
|
||||||
|
|
||||||
All planned phases are now implemented. Possible later work: per-stream audio
|
All planned phases are now implemented. Possible later work: per-stream audio
|
||||||
mixing for multi-stream games, per-stream format detection for the audio hook, and
|
mixing for multi-stream games, per-stream format detection for the audio hook, and
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
|||||||
ImGui::TextDisabled("This window is what Remote Play Together captures.");
|
ImGui::TextDisabled("This window is what Remote Play Together captures.");
|
||||||
ImGui::TextDisabled("F1: hide overlay (clean mirror) Esc: quit");
|
ImGui::TextDisabled("F1: hide overlay (clean mirror) Esc: quit");
|
||||||
|
|
||||||
|
#ifdef COOP_WITH_STEAM
|
||||||
|
ImGui::Checkbox("Use Steam Input (experimental)", &steam_requested_);
|
||||||
|
if (steam_requested_)
|
||||||
|
{
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextColored(steam_active_ ? kGreen : kGrey, steam_active_ ? "(active)" : "(starting...)");
|
||||||
|
ImGui::TextDisabled("Needs a controller bound to Steam Input for this app; otherwise");
|
||||||
|
ImGui::TextDisabled("XInput is hidden and no input arrives. Leave off for plain XInput.");
|
||||||
|
}
|
||||||
|
if (steam_note_[0] != '\0')
|
||||||
|
{
|
||||||
|
ImGui::TextColored(kGrey, "%s", steam_note_);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// --- Guest pads the host receives from RPT -----------------------------
|
// --- Guest pads the host receives from RPT -----------------------------
|
||||||
ImGui::SeparatorText("Incoming (host receives)");
|
ImGui::SeparatorText("Incoming (host receives)");
|
||||||
const auto& pads = input.pads();
|
const auto& pads = input.pads();
|
||||||
|
|||||||
@@ -21,11 +21,39 @@ public:
|
|||||||
// reveals the raw axis values and the per-slot poll-rate table.
|
// reveals the raw axis values and the per-slot poll-rate table.
|
||||||
void draw(const InputSource& input, const HookStatusView& status, bool debug_details);
|
void draw(const InputSource& input, const HookStatusView& status, bool debug_details);
|
||||||
|
|
||||||
|
#ifdef COOP_WITH_STEAM
|
||||||
|
// Whether the operator has opted into Steam Input. It's off by default: simply
|
||||||
|
// initializing Steam Input activates Steam's in-process XInput interception,
|
||||||
|
// which hides controllers from XInput unless they're bound to our action set for
|
||||||
|
// this app -- so it can silently break the (working) XInput path. main reconciles
|
||||||
|
// this against the actual backend each frame.
|
||||||
|
[[nodiscard]] bool steam_input_requested() const
|
||||||
|
{
|
||||||
|
return steam_requested_;
|
||||||
|
}
|
||||||
|
void set_steam_active(bool active)
|
||||||
|
{
|
||||||
|
steam_active_ = active;
|
||||||
|
}
|
||||||
|
void on_steam_init_failed()
|
||||||
|
{
|
||||||
|
steam_requested_ = false;
|
||||||
|
steam_active_ = false;
|
||||||
|
steam_note_ = "Steam Input failed to start; using XInput.";
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Sampled to turn the hook's cumulative per-slot counters into poll rates.
|
// Sampled to turn the hook's cumulative per-slot counters into poll rates.
|
||||||
unsigned long long last_state_count_[kMaxPads] = {};
|
unsigned long long last_state_count_[kMaxPads] = {};
|
||||||
double state_rate_[kMaxPads] = {};
|
double state_rate_[kMaxPads] = {};
|
||||||
double last_sample_time_ = 0.0;
|
double last_sample_time_ = 0.0;
|
||||||
|
|
||||||
|
#ifdef COOP_WITH_STEAM
|
||||||
|
bool steam_requested_ = false; // operator opted into Steam Input (default off)
|
||||||
|
bool steam_active_ = false; // Steam Input actually initialized
|
||||||
|
const char* steam_note_ = "";
|
||||||
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace coop
|
} // namespace coop
|
||||||
|
|||||||
@@ -86,17 +86,16 @@ int run()
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steam Input is the plan's primary guest-input path (it falls back to XInput
|
// XInput is the default guest-input path: Remote Play Together delivers guest
|
||||||
// per slot, and to pure XInput if Steam isn't available). When the host is built
|
// pads as XInput, and it Just Works. Steam Input is opt-in (Controllers panel) --
|
||||||
// without the Steamworks SDK, use XInput directly.
|
// merely initializing it activates Steam's in-process XInput interception, which
|
||||||
|
// hides controllers from XInput unless they're bound to our action set for this
|
||||||
|
// app, so making it the default can silently break input. We switch the active
|
||||||
|
// backend at runtime to match the toggle.
|
||||||
|
coop::XInputSource xinput;
|
||||||
|
coop::InputSource* input = &xinput;
|
||||||
#ifdef COOP_WITH_STEAM
|
#ifdef COOP_WITH_STEAM
|
||||||
std::unique_ptr<coop::InputSource> input = [] {
|
std::unique_ptr<coop::SteamInputSource> steam;
|
||||||
auto steam = std::make_unique<coop::SteamInputSource>();
|
|
||||||
steam->init(steam_manifest_path());
|
|
||||||
return steam;
|
|
||||||
}();
|
|
||||||
#else
|
|
||||||
std::unique_ptr<coop::InputSource> input = std::make_unique<coop::XInputSource>();
|
|
||||||
#endif
|
#endif
|
||||||
coop::ControllersPanel controllers;
|
coop::ControllersPanel controllers;
|
||||||
coop::InjectionPanel injection;
|
coop::InjectionPanel injection;
|
||||||
@@ -119,6 +118,31 @@ int run()
|
|||||||
|
|
||||||
while (window.pump_messages())
|
while (window.pump_messages())
|
||||||
{
|
{
|
||||||
|
#ifdef COOP_WITH_STEAM
|
||||||
|
// Switch the active input backend to match the Controllers-panel toggle.
|
||||||
|
const bool want_steam = controllers.steam_input_requested();
|
||||||
|
if (want_steam && steam == nullptr)
|
||||||
|
{
|
||||||
|
steam = std::make_unique<coop::SteamInputSource>();
|
||||||
|
if (steam->init(steam_manifest_path()))
|
||||||
|
{
|
||||||
|
input = steam.get();
|
||||||
|
controllers.set_steam_active(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
steam.reset();
|
||||||
|
controllers.on_steam_init_failed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!want_steam && steam != nullptr)
|
||||||
|
{
|
||||||
|
steam->shutdown();
|
||||||
|
steam.reset();
|
||||||
|
input = &xinput;
|
||||||
|
controllers.set_steam_active(false);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
input->poll();
|
input->poll();
|
||||||
injection.publish(input->pads());
|
injection.publish(input->pads());
|
||||||
const HWND game = injection.game_hwnd();
|
const HWND game = injection.game_hwnd();
|
||||||
|
|||||||
Reference in New Issue
Block a user