diff --git a/.gitignore b/.gitignore index dc3038f..a5c554c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ CMakeUserPresets.json x64 Debug Release + +# Steamworks SDK: vendored locally, not redistributable -- never commit it. +/steamworks_sdk_*.zip +/third_party/steamworks_sdk/ diff --git a/README.md b/README.md index 52292a2..92134e7 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 | XInput (RPT delivers guest pads to the focused window) | `coop_host.exe` | done | +| 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 | | 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 | @@ -155,10 +155,21 @@ Done: all subsystems hooked, and the IPC channels (status, audio ring, video share, log) all flow across the x64↔x86 boundary. -Future work, roughly in priority order: +- **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_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. -- **Steam Input:** consume guest input through the Steam Input API directly - rather than 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 +hooking D3D9 / pure-D3D12 present paths for the video hook. ## Building @@ -180,8 +191,14 @@ configured into `build/x86/`) that produces `coop_hook_x86.dll` and it with `-DCOOP_BUILD_X86_HELPER=OFF` if you don't need 32-bit support. Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under -`third_party/`; the Steamworks SDK is vendored manually there when wired. No -vcpkg / package manager is used. +`third_party/`. No vcpkg / package manager is used. + +**Steam Input is optional.** It's enabled automatically when the Steamworks SDK is +vendored at `third_party/steamworks_sdk/` (extract the `steamworks_sdk_*.zip` +there). The SDK isn't redistributable, so it's gitignored and never committed; if +it's absent the host builds XInput-only (no other features depend on it). When +present, the build links `steam_api64.lib`, stages `steam_api64.dll` and the +action manifest next to the host, and also builds `coop_steam_input_probe`. ### clangd / IDE setup diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index aadd20a..061d7bd 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -37,3 +37,50 @@ target_link_libraries(coop_host PRIVATE ole32) set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host") + +# Optional Steam Input: enabled automatically when the Steamworks SDK is vendored +# under third_party/steamworks_sdk (it isn't redistributable, so it's gitignored +# and never committed). Without it, the host builds XInput-only. +set(COOP_STEAM_SDK_DIR "${CMAKE_SOURCE_DIR}/third_party/steamworks_sdk/sdk") +if(EXISTS "${COOP_STEAM_SDK_DIR}/public/steam/steam_api.h") + message(STATUS "Steamworks SDK found -> Steam Input enabled") + target_sources(coop_host PRIVATE src/input/steam_input_source.cpp) + target_compile_definitions(coop_host PRIVATE COOP_WITH_STEAM) + target_include_directories(coop_host SYSTEM PRIVATE "${COOP_STEAM_SDK_DIR}/public") + # The SDK headers trip /W4 (strncpy, non-UTF-8 bytes); silence for this TU only. + set_source_files_properties(src/input/steam_input_source.cpp PROPERTIES + COMPILE_OPTIONS "/wd4996;/wd4828") + target_link_libraries(coop_host PRIVATE + "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.lib") + # Stage steam_api64.dll + the action manifest next to coop_host.exe. + add_custom_command(TARGET coop_host POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.dll" + "$/steam_api64.dll" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/assets/steam_input_actions.vdf" + "$/steam_input_actions.vdf" + VERBATIM) + + # Console smoke test for Steam Input (reuses the shipping source). Not a ctest + # (needs Steam running + an appid); run manually from bin//. + add_executable(coop_steam_input_probe + ${CMAKE_SOURCE_DIR}/tools/steam_input_probe/main.cpp + src/input/steam_input_source.cpp + src/input/xinput_source.cpp) + target_include_directories(coop_steam_input_probe PRIVATE src) + target_include_directories(coop_steam_input_probe SYSTEM PRIVATE "${COOP_STEAM_SDK_DIR}/public") + target_compile_definitions(coop_steam_input_probe PRIVATE COOP_WITH_STEAM) + set_source_files_properties(src/input/steam_input_source.cpp PROPERTIES COMPILE_OPTIONS "/wd4996;/wd4828") + target_link_libraries(coop_steam_input_probe PRIVATE + coop_common + xinput + "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.lib") + add_custom_command(TARGET coop_steam_input_probe POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.dll" + "$/steam_api64.dll" + VERBATIM) +else() + message(STATUS "Steamworks SDK not found -> Steam Input disabled (XInput only)") +endif() diff --git a/host/assets/steam_input_actions.vdf b/host/assets/steam_input_actions.vdf new file mode 100644 index 0000000..e435fae --- /dev/null +++ b/host/assets/steam_input_actions.vdf @@ -0,0 +1,84 @@ +// Steam Input In-Game Actions (IGA) manifest for CoopAllTheThings. +// +// SteamInputSource points Steam Input at this file via +// ISteamInput::SetInputActionManifestFilePath, so the host doesn't depend on a +// partner-backend-registered config. It declares one action set, "GameControls", +// with the standard gamepad buttons + two sticks + two triggers. The action names +// here must match host/src/input/steam_input_source.cpp. +"In Game Actions" +{ + "actions" + { + "GameControls" + { + "title" "#Set_GameControls" + "legacy_set" "1" + + "StickPadGyro" + { + "LeftStick" + { + "title" "#Action_LeftStick" + "input_mode" "joystick_move" + } + "RightStick" + { + "title" "#Action_RightStick" + "input_mode" "joystick_move" + } + } + + "AnalogTrigger" + { + "LeftTrigger" "#Action_LeftTrigger" + "RightTrigger" "#Action_RightTrigger" + } + + "Button" + { + "A" "#Action_A" + "B" "#Action_B" + "X" "#Action_X" + "Y" "#Action_Y" + "LB" "#Action_LB" + "RB" "#Action_RB" + "Back" "#Action_Back" + "Start" "#Action_Start" + "LStickClick" "#Action_LStickClick" + "RStickClick" "#Action_RStickClick" + "DPadUp" "#Action_DPadUp" + "DPadDown" "#Action_DPadDown" + "DPadLeft" "#Action_DPadLeft" + "DPadRight" "#Action_DPadRight" + "Guide" "#Action_Guide" + } + } + } + + "localization" + { + "english" + { + "Set_GameControls" "Game Controls" + "Action_LeftStick" "Left Stick" + "Action_RightStick" "Right Stick" + "Action_LeftTrigger" "Left Trigger" + "Action_RightTrigger" "Right Trigger" + "Action_A" "A" + "Action_B" "B" + "Action_X" "X" + "Action_Y" "Y" + "Action_LB" "Left Bumper" + "Action_RB" "Right Bumper" + "Action_Back" "Back" + "Action_Start" "Start" + "Action_LStickClick" "Left Stick Click" + "Action_RStickClick" "Right Stick Click" + "Action_DPadUp" "D-Pad Up" + "Action_DPadDown" "D-Pad Down" + "Action_DPadLeft" "D-Pad Left" + "Action_DPadRight" "D-Pad Right" + "Action_Guide" "Guide" + } + } +} diff --git a/host/src/input/steam_input_source.cpp b/host/src/input/steam_input_source.cpp new file mode 100644 index 0000000..930aa50 --- /dev/null +++ b/host/src/input/steam_input_source.cpp @@ -0,0 +1,205 @@ +#include "input/steam_input_source.hpp" + +#include + +#include +#include // XINPUT_GAMEPAD_* button bits + +#include + +namespace coop +{ + +namespace +{ + +// Digital actions in the manifest, paired with the XInput button bit they map to. +// Names must match host/assets/steam_input_actions.vdf. +struct ButtonAction +{ + const char* action; + std::uint16_t xinput_bit; +}; +const ButtonAction kButtons[kSteamButtonActions] = { + {"A", XINPUT_GAMEPAD_A}, + {"B", XINPUT_GAMEPAD_B}, + {"X", XINPUT_GAMEPAD_X}, + {"Y", XINPUT_GAMEPAD_Y}, + {"LB", XINPUT_GAMEPAD_LEFT_SHOULDER}, + {"RB", XINPUT_GAMEPAD_RIGHT_SHOULDER}, + {"Back", XINPUT_GAMEPAD_BACK}, + {"Start", XINPUT_GAMEPAD_START}, + {"LStickClick", XINPUT_GAMEPAD_LEFT_THUMB}, + {"RStickClick", XINPUT_GAMEPAD_RIGHT_THUMB}, + {"DPadUp", XINPUT_GAMEPAD_DPAD_UP}, + {"DPadDown", XINPUT_GAMEPAD_DPAD_DOWN}, + {"DPadLeft", XINPUT_GAMEPAD_DPAD_LEFT}, + {"DPadRight", XINPUT_GAMEPAD_DPAD_RIGHT}, + {"Guide", 0x0400}, // unofficial XINPUT_GAMEPAD_GUIDE +}; + +std::int16_t to_axis(float v) +{ + if (v > 1.0f) + { + v = 1.0f; + } + if (v < -1.0f) + { + v = -1.0f; + } + return static_cast(v * 32767.0f); +} + +std::uint8_t to_trigger(float v) +{ + if (v > 1.0f) + { + v = 1.0f; + } + if (v < 0.0f) + { + v = 0.0f; + } + return static_cast(v * 255.0f); +} + +} // namespace + +SteamInputSource::~SteamInputSource() +{ + shutdown(); +} + +bool SteamInputSource::init(const std::string& manifest_absolute_path) +{ + // Running standalone (not launched by Steam) without a steam_appid.txt makes + // SteamAPI_Init fail; that's fine -- we degrade to XInput. + if (!SteamAPI_Init()) + { + std::printf("SteamInput: SteamAPI_Init failed (not under Steam?); using XInput.\n"); + return false; + } + if (SteamInput() == nullptr) + { + std::printf("SteamInput: ISteamInput unavailable; using XInput.\n"); + SteamAPI_Shutdown(); + return false; + } + // Point Steam Input at our bundled action manifest so we don't depend on a + // partner-backend-registered config. Must be called before Init(). + if (!manifest_absolute_path.empty()) + { + SteamInput()->SetInputActionManifestFilePath(manifest_absolute_path.c_str()); + } + if (!SteamInput()->Init(/*bExplicitlyCallRunFrame=*/false)) + { + std::printf("SteamInput: ISteamInput::Init failed; using XInput.\n"); + SteamAPI_Shutdown(); + return false; + } + + steam_ready_ = true; + name_ = "Steam Input (XInput fallback)"; + resolve_handles(); + std::printf("SteamInput: initialized (manifest=%s).\n", manifest_absolute_path.c_str()); + return true; +} + +void SteamInputSource::shutdown() +{ + if (steam_ready_) + { + SteamInput()->Shutdown(); + SteamAPI_Shutdown(); + steam_ready_ = false; + name_ = "XInput"; + } +} + +void SteamInputSource::resolve_handles() +{ + action_set_ = SteamInput()->GetActionSetHandle("GameControls"); + for (int i = 0; i < kSteamButtonActions; ++i) + { + button_handles_[i] = SteamInput()->GetDigitalActionHandle(kButtons[i].action); + } + left_stick_ = SteamInput()->GetAnalogActionHandle("LeftStick"); + right_stick_ = SteamInput()->GetAnalogActionHandle("RightStick"); + left_trigger_ = SteamInput()->GetAnalogActionHandle("LeftTrigger"); + right_trigger_ = SteamInput()->GetAnalogActionHandle("RightTrigger"); +} + +bool SteamInputSource::read_steam_pad(std::uint64_t controller, PadInfo& out) const +{ + SteamInput()->ActivateActionSet(controller, action_set_); + + CoopPadState st{}; + st.connected = 1; + bool any_active = false; + + for (int i = 0; i < kSteamButtonActions; ++i) + { + const InputDigitalActionData_t d = SteamInput()->GetDigitalActionData(controller, button_handles_[i]); + any_active = any_active || d.bActive; + if (d.bState) + { + st.buttons |= kButtons[i].xinput_bit; + } + } + + const InputAnalogActionData_t ls = SteamInput()->GetAnalogActionData(controller, left_stick_); + const InputAnalogActionData_t rs = SteamInput()->GetAnalogActionData(controller, right_stick_); + const InputAnalogActionData_t lt = SteamInput()->GetAnalogActionData(controller, left_trigger_); + const InputAnalogActionData_t rt = SteamInput()->GetAnalogActionData(controller, right_trigger_); + any_active = any_active || ls.bActive || rs.bActive || lt.bActive || rt.bActive; + + st.thumb_lx = to_axis(ls.x); + st.thumb_ly = to_axis(ls.y); + st.thumb_rx = to_axis(rs.x); + st.thumb_ry = to_axis(rs.y); + st.left_trigger = to_trigger(lt.x); + st.right_trigger = to_trigger(rt.x); + + // No action is bound/active (e.g. the controller isn't using our manifest) -> + // let the XInput fallback handle this slot instead of reporting an empty pad. + if (!any_active) + { + return false; + } + + static std::uint32_t s_packet = 0; + st.packet = ++s_packet; + out.connected = true; + out.state = st; + out.source = "Steam Input"; + return true; +} + +void SteamInputSource::poll() +{ + // XInput first so every slot has a fallback value; Steam overrides where active. + xinput_.poll(); + pads_ = xinput_.pads(); + + if (!steam_ready_) + { + steam_count_ = 0; + return; + } + + SteamAPI_RunCallbacks(); + InputHandle_t handles[STEAM_INPUT_MAX_COUNT] = {}; + steam_count_ = SteamInput()->GetConnectedControllers(handles); + + for (int i = 0; i < steam_count_ && i < static_cast(kMaxPads); ++i) + { + PadInfo steam_pad; + if (read_steam_pad(handles[i], steam_pad)) + { + pads_[i] = steam_pad; // Steam controller active on this slot -> use it + } + } +} + +} // namespace coop diff --git a/host/src/input/steam_input_source.hpp b/host/src/input/steam_input_source.hpp new file mode 100644 index 0000000..17f8318 --- /dev/null +++ b/host/src/input/steam_input_source.hpp @@ -0,0 +1,72 @@ +// Receives guest controllers through the Steam Input API (action-based) -- the +// plan's "primary" input path -- and falls back to XInput per slot for any slot +// Steam Input doesn't cover. If SteamAPI / Steam Input can't initialize (e.g. the +// host isn't running under Steam, or no action manifest binds), this transparently +// behaves as plain XInput, so it's always safe to use. +// +// Compiled only when the host is built with the Steamworks SDK (COOP_WITH_STEAM). +#pragma once + +#include +#include +#include + +#include "input/input_source.hpp" +#include "input/xinput_source.hpp" + +namespace coop +{ + +// Number of digital (button) actions in the bundled action manifest. +inline constexpr int kSteamButtonActions = 15; + +class SteamInputSource final : public InputSource +{ +public: + ~SteamInputSource() override; + + // Initializes SteamAPI + Steam Input and loads the action manifest at the given + // absolute path. Returns true if Steam Input is live; false means it will run + // as XInput-only (poll() still works either way). + bool init(const std::string& manifest_absolute_path); + void shutdown(); + + [[nodiscard]] const char* name() const override + { + return name_; + } + void poll() override; + [[nodiscard]] const std::array& pads() const override + { + return pads_; + } + + [[nodiscard]] bool steam_active() const + { + return steam_ready_; + } + [[nodiscard]] int steam_controllers() const + { + return steam_count_; + } + +private: + void resolve_handles(); + bool read_steam_pad(std::uint64_t controller, PadInfo& out) const; + + XInputSource xinput_; // fallback for slots Steam Input doesn't fill + std::array pads_; + + bool steam_ready_ = false; + int steam_count_ = 0; + const char* name_ = "XInput"; + + std::uint64_t action_set_ = 0; + std::uint64_t button_handles_[kSteamButtonActions] = {}; + std::uint64_t left_stick_ = 0; + std::uint64_t right_stick_ = 0; + std::uint64_t left_trigger_ = 0; + std::uint64_t right_trigger_ = 0; +}; + +} // namespace coop diff --git a/host/src/main.cpp b/host/src/main.cpp index 701d939..d15e26c 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -23,9 +23,31 @@ #include "log_panel.hpp" #include "ui/app_chrome.hpp" +#ifdef COOP_WITH_STEAM +#include + +#include "input/steam_input_source.hpp" +#endif + namespace { +#ifdef COOP_WITH_STEAM +// Absolute path to the bundled Steam Input action manifest (next to the exe). +std::string steam_manifest_path() +{ + char buffer[MAX_PATH] = {}; + const DWORD len = GetModuleFileNameA(nullptr, buffer, MAX_PATH); + std::string path(buffer, len); + const std::size_t slash = path.find_last_of("\\/"); + if (slash != std::string::npos) + { + path.resize(slash + 1); + } + return path + "steam_input_actions.vdf"; +} +#endif + // When the overlay is hidden, briefly show a fading hint so the operator can find // the way back. The window is borderless and non-interactive so it never steals a // click or a frame from the mirror underneath. @@ -64,7 +86,18 @@ int run() return 1; } - auto input = std::make_unique(); + // 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. +#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(); +#endif coop::ControllersPanel controllers; coop::InjectionPanel injection; coop::AudioPanel audio; diff --git a/tools/steam_input_probe/main.cpp b/tools/steam_input_probe/main.cpp new file mode 100644 index 0000000..6a7d0ca --- /dev/null +++ b/tools/steam_input_probe/main.cpp @@ -0,0 +1,59 @@ +// coop_steam_input_probe -- console smoke test for SteamInputSource. Brings up +// Steam Input via the shipping host code (no GUI), prints whether it initialized, +// how many Steam controllers are connected, and a few polled snapshots, then +// exits. Needs Steam running and an appid context: either launch it under Steam, +// or drop a steam_appid.txt (e.g. "480") next to the exe. Built only when the +// Steamworks SDK is vendored. + +#include + +#include + +#include "input/steam_input_source.hpp" + +namespace +{ +std::string manifest_path() +{ + char buffer[MAX_PATH] = {}; + const DWORD len = GetModuleFileNameA(nullptr, buffer, MAX_PATH); + std::string path(buffer, len); + const std::size_t slash = path.find_last_of("\\/"); + if (slash != std::string::npos) + { + path.resize(slash + 1); + } + return path + "steam_input_actions.vdf"; +} +} // namespace + +int main() +{ + coop::SteamInputSource src; + const bool ok = src.init(manifest_path()); + std::printf("init=%d steam_active=%d backend=\"%s\"\n", ok ? 1 : 0, src.steam_active() ? 1 : 0, + src.name()); + + for (int frame = 0; frame < 20; ++frame) + { + src.poll(); + Sleep(50); + } + + std::printf("steam_controllers=%d\n", src.steam_controllers()); + const auto& pads = src.pads(); + for (std::uint32_t i = 0; i < coop::kMaxPads; ++i) + { + const coop::PadInfo& p = pads[i]; + if (p.connected) + { + std::printf(" slot %u: source=\"%s\" buttons=0x%04X LX=%d LY=%d LT=%u RT=%u\n", i, + p.source.c_str(), p.state.buttons, p.state.thumb_lx, p.state.thumb_ly, + p.state.left_trigger, p.state.right_trigger); + } + } + + src.shutdown(); + std::printf("done.\n"); + return 0; +}