diff --git a/README.md b/README.md index f963a6c..2417437 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ XInput game becomes Remote-Play-Together-able. | 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 | | 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 | @@ -158,17 +158,19 @@ Done: all subsystems hooked, and the IPC channels (status, audio ring, video share, log) all flow across the x64↔x86 boundary. -- **Steam Input. ✅** When the host is built with the Steamworks SDK - (auto-detected under `third_party/steamworks_sdk/`), guest input comes through - the Steam Input API (action-based) as the primary path, falling back to XInput - per slot — and to pure XInput if Steam isn't available, so the host always runs. - It initializes SteamAPI + Steam Input and loads a bundled action manifest +- **Steam Input (opt-in). ✅** When the host is built with the Steamworks SDK + (auto-detected under `third_party/steamworks_sdk/`), a **Use Steam Input** + checkbox in the Controllers panel switches the input backend to the Steam Input + API (action-based) at runtime; it loads a bundled action manifest (`steam_input_actions.vdf`) via `SetInputActionManifestFilePath`, so it needs no - partner-backend config. Verified to initialize against the live Steam client and - enumerate controllers (`coop_steam_input_probe`). Reading actual button/stick - state still requires a controller bound through Steam Input for the running - appid (the donor appid under RPT) — without that binding the XInput path carries - the guest input, as before. + partner-backend config. **It's off by default and XInput is the primary path**: + merely initializing Steam Input activates Steam's in-process XInput interception, + which *hides* controllers from XInput unless they're bound to our action set for + the running appid — so defaulting to it silently broke input forwarding. Enabling + 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 mixing for multi-stream games, per-stream format detection for the audio hook, and diff --git a/host/src/controllers_panel.cpp b/host/src/controllers_panel.cpp index 03b37a7..4aed542 100644 --- a/host/src/controllers_panel.cpp +++ b/host/src/controllers_panel.cpp @@ -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("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 ----------------------------- ImGui::SeparatorText("Incoming (host receives)"); const auto& pads = input.pads(); diff --git a/host/src/controllers_panel.hpp b/host/src/controllers_panel.hpp index a1edad9..730702e 100644 --- a/host/src/controllers_panel.hpp +++ b/host/src/controllers_panel.hpp @@ -21,11 +21,39 @@ public: // reveals the raw axis values and the per-slot poll-rate table. 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: // Sampled to turn the hook's cumulative per-slot counters into poll rates. unsigned long long last_state_count_[kMaxPads] = {}; double state_rate_[kMaxPads] = {}; 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 diff --git a/host/src/main.cpp b/host/src/main.cpp index d15e26c..df578d9 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -86,17 +86,16 @@ int run() return 1; } - // Steam Input is the plan's primary guest-input path (it falls back to XInput - // per slot, and to pure XInput if Steam isn't available). When the host is built - // without the Steamworks SDK, use XInput directly. + // XInput is the default guest-input path: Remote Play Together delivers guest + // pads as XInput, and it Just Works. Steam Input is opt-in (Controllers panel) -- + // 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 - std::unique_ptr input = [] { - auto steam = std::make_unique(); - steam->init(steam_manifest_path()); - return steam; - }(); -#else - std::unique_ptr input = std::make_unique(); + std::unique_ptr steam; #endif coop::ControllersPanel controllers; coop::InjectionPanel injection; @@ -119,6 +118,31 @@ int run() 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(); + 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(); injection.publish(input->pads()); const HWND game = injection.game_hwnd();