Steam Input as the primary guest-input path (optional, SDK-gated)

Add SteamInputSource: initializes SteamAPI + Steam Input, loads a bundled action
manifest via SetInputActionManifestFilePath (no partner-backend config needed),
and reads the GameControls action set into CoopPadState -- falling back to XInput
per slot, and to pure XInput if Steam isn't available, so the host always runs.

Enabled automatically when the Steamworks SDK is vendored at
third_party/steamworks_sdk/ (auto-detected by CMake; gitignored and never
committed -- the build is XInput-only without it). Stages steam_api64.dll + the
manifest next to the host and builds coop_steam_input_probe (a console smoke test).

Verified: the probe initializes against the live Steam client and enumerates
controllers; the host degrades gracefully when launched standalone. All 5 tests
pass. Reading actual controller state needs a pad bound through Steam Input for
the running (donor) appid, which XInput otherwise covers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 11:51:58 +02:00
parent b1f8783321
commit edf2da69d8
8 changed files with 528 additions and 7 deletions

View File

@@ -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"
"$<TARGET_FILE_DIR:coop_host>/steam_api64.dll"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/assets/steam_input_actions.vdf"
"$<TARGET_FILE_DIR:coop_host>/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/<config>/.
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"
"$<TARGET_FILE_DIR:coop_steam_input_probe>/steam_api64.dll"
VERBATIM)
else()
message(STATUS "Steamworks SDK not found -> Steam Input disabled (XInput only)")
endif()

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,205 @@
#include "input/steam_input_source.hpp"
#include <cstdio>
#include <windows.h>
#include <xinput.h> // XINPUT_GAMEPAD_* button bits
#include <steam/steam_api.h>
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<std::int16_t>(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<std::uint8_t>(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<int>(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

View File

@@ -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 <array>
#include <cstdint>
#include <string>
#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<PadInfo, kMaxPads>& 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<PadInfo, kMaxPads> 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

View File

@@ -23,9 +23,31 @@
#include "log_panel.hpp"
#include "ui/app_chrome.hpp"
#ifdef COOP_WITH_STEAM
#include <string>
#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<coop::XInputSource>();
// 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<coop::InputSource> input = [] {
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
coop::ControllersPanel controllers;
coop::InjectionPanel injection;
coop::AudioPanel audio;