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:
205
host/src/input/steam_input_source.cpp
Normal file
205
host/src/input/steam_input_source.cpp
Normal 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
|
||||
72
host/src/input/steam_input_source.hpp
Normal file
72
host/src/input/steam_input_source.hpp
Normal 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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user