Support Rumble on Windows
This commit is contained in:
parent
5ff5942258
commit
1fda8f6219
11 changed files with 165 additions and 43 deletions
|
|
@ -46,7 +46,7 @@ void free_id(std::bitset<N> &gamepad_mask, int id) {
|
|||
gamepad_mask[id] = false;
|
||||
}
|
||||
|
||||
static util::TaskPool::task_id_t task_id {};
|
||||
static util::TaskPool::task_id_t key_press_repeat_id {};
|
||||
static std::unordered_map<short, bool> key_press {};
|
||||
static std::array<std::uint8_t, 5> mouse_press {};
|
||||
|
||||
|
|
@ -84,10 +84,13 @@ struct gamepad_t {
|
|||
};
|
||||
|
||||
struct input_t {
|
||||
input_t(safe::mail_raw_t::event_t<input::touch_port_t> touch_port_event)
|
||||
input_t(
|
||||
safe::mail_raw_t::event_t<input::touch_port_t> touch_port_event,
|
||||
platf::rumble_queue_t rumble_queue)
|
||||
: active_gamepad_state {},
|
||||
gamepads(MAX_GAMEPADS),
|
||||
touch_port_event { std::move(touch_port_event) },
|
||||
rumble_queue { std::move(rumble_queue) },
|
||||
mouse_left_button_timeout {},
|
||||
touch_port { 0, 0, 0, 0, 0, 0, 1.0f } {}
|
||||
|
||||
|
|
@ -95,6 +98,7 @@ struct input_t {
|
|||
std::vector<gamepad_t> gamepads;
|
||||
|
||||
safe::mail_raw_t::event_t<input::touch_port_t> touch_port_event;
|
||||
platf::rumble_queue_t rumble_queue;
|
||||
|
||||
util::ThreadPool::task_id_t mouse_left_button_timeout;
|
||||
|
||||
|
|
@ -314,13 +318,13 @@ void passthrough(std::shared_ptr<input_t> &input, PNV_MOUSE_BUTTON_PACKET packet
|
|||
void repeat_key(short key_code) {
|
||||
// If key no longer pressed, stop repeating
|
||||
if(!key_press[key_code]) {
|
||||
task_id = nullptr;
|
||||
key_press_repeat_id = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
platf::keyboard(platf_input, key_code & 0x00FF, false);
|
||||
|
||||
task_id = task_pool.pushDelayed(repeat_key, config::input.key_repeat_period, key_code).task_id;
|
||||
key_press_repeat_id = task_pool.pushDelayed(repeat_key, config::input.key_repeat_period, key_code).task_id;
|
||||
}
|
||||
|
||||
short map_keycode(short keycode) {
|
||||
|
|
@ -345,12 +349,12 @@ void passthrough(std::shared_ptr<input_t> &input, PNV_KEYBOARD_PACKET packet) {
|
|||
auto &pressed = key_press[packet->keyCode];
|
||||
if(!pressed) {
|
||||
if(!release) {
|
||||
if(task_id) {
|
||||
task_pool.cancel(task_id);
|
||||
if(key_press_repeat_id) {
|
||||
task_pool.cancel(key_press_repeat_id);
|
||||
}
|
||||
|
||||
if(config::input.key_repeat_delay.count() > 0) {
|
||||
task_id = task_pool.pushDelayed(repeat_key, config::input.key_repeat_delay, packet->keyCode).task_id;
|
||||
key_press_repeat_id = task_pool.pushDelayed(repeat_key, config::input.key_repeat_delay, packet->keyCode).task_id;
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
@ -374,7 +378,7 @@ void passthrough(PNV_SCROLL_PACKET packet) {
|
|||
platf::scroll(platf_input, util::endian::big(packet->scrollAmt1));
|
||||
}
|
||||
|
||||
int updateGamepads(std::vector<gamepad_t> &gamepads, std::int16_t old_state, std::int16_t new_state) {
|
||||
int updateGamepads(std::vector<gamepad_t> &gamepads, std::int16_t old_state, std::int16_t new_state, platf::rumble_queue_t rumble_queue) {
|
||||
auto xorGamepadMask = old_state ^ new_state;
|
||||
if(!xorGamepadMask) {
|
||||
return 0;
|
||||
|
|
@ -400,7 +404,7 @@ int updateGamepads(std::vector<gamepad_t> &gamepads, std::int16_t old_state, std
|
|||
return -1;
|
||||
}
|
||||
|
||||
if(platf::alloc_gamepad(platf_input, id)) {
|
||||
if(platf::alloc_gamepad(platf_input, id, std::move(rumble_queue))) {
|
||||
free_id(gamepadMask, id);
|
||||
// allocating a gamepad failed: solution: ignore gamepads
|
||||
// The implementations of platf::alloc_gamepad already has logging
|
||||
|
|
@ -416,7 +420,7 @@ int updateGamepads(std::vector<gamepad_t> &gamepads, std::int16_t old_state, std
|
|||
}
|
||||
|
||||
void passthrough(std::shared_ptr<input_t> &input, PNV_MULTI_CONTROLLER_PACKET packet) {
|
||||
if(updateGamepads(input->gamepads, input->active_gamepad_state, packet->activeGamepadMask)) {
|
||||
if(updateGamepads(input->gamepads, input->active_gamepad_state, packet->activeGamepadMask, input->rumble_queue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -552,7 +556,7 @@ void passthrough(std::shared_ptr<input_t> &input, std::vector<std::uint8_t> &&in
|
|||
}
|
||||
|
||||
void reset(std::shared_ptr<input_t> &input) {
|
||||
task_pool.cancel(task_id);
|
||||
task_pool.cancel(key_press_repeat_id);
|
||||
task_pool.cancel(input->mouse_left_button_timeout);
|
||||
|
||||
// Ensure input is synchronous, by using the task_pool
|
||||
|
|
@ -576,7 +580,9 @@ void init() {
|
|||
}
|
||||
|
||||
std::shared_ptr<input_t> alloc(safe::mail_t mail) {
|
||||
auto input = std::make_shared<input_t>(mail->event<input::touch_port_t>(mail::touch_port));
|
||||
auto input = std::make_shared<input_t>(
|
||||
mail->event<input::touch_port_t>(mail::touch_port),
|
||||
mail->queue<platf::rumble_t>(mail::rumble));
|
||||
|
||||
// Workaround to ensure new frames will be captured when a client connects
|
||||
task_pool.pushDelayed([]() {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@
|
|||
#ifndef SUNSHINE_INPUT_H
|
||||
#define SUNSHINE_INPUT_H
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "platform/common.h"
|
||||
#include "thread_safe.h"
|
||||
|
||||
namespace input {
|
||||
|
||||
struct input_t;
|
||||
|
||||
void print(void *input);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ MAIL(audio_packets);
|
|||
// Local mail
|
||||
MAIL(touch_port);
|
||||
MAIL(idr);
|
||||
MAIL(rumble);
|
||||
#undef MAIL
|
||||
} // namespace mail
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
|
||||
#include <bitset>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "sunshine/thread_safe.h"
|
||||
#include "sunshine/utility.h"
|
||||
|
||||
struct sockaddr;
|
||||
|
|
@ -34,6 +36,18 @@ constexpr std::uint16_t B = 0x2000;
|
|||
constexpr std::uint16_t X = 0x4000;
|
||||
constexpr std::uint16_t Y = 0x8000;
|
||||
|
||||
struct rumble_t {
|
||||
KITTY_DEFAULT_CONSTR(rumble_t)
|
||||
|
||||
rumble_t(std::uint16_t id, std::uint16_t lowfreq, std::uint16_t highfreq)
|
||||
: id { id }, lowfreq { lowfreq }, highfreq { highfreq } {}
|
||||
|
||||
std::uint16_t id;
|
||||
std::uint16_t lowfreq;
|
||||
std::uint16_t highfreq;
|
||||
};
|
||||
using rumble_queue_t = safe::mail_raw_t::queue_t<rumble_t>;
|
||||
|
||||
namespace speaker {
|
||||
enum speaker_e {
|
||||
FRONT_LEFT,
|
||||
|
|
@ -243,7 +257,7 @@ void scroll(input_t &input, int distance);
|
|||
void keyboard(input_t &input, uint16_t modcode, bool release);
|
||||
void gamepad(input_t &input, int nr, const gamepad_state_t &gamepad_state);
|
||||
|
||||
int alloc_gamepad(input_t &input, int nr);
|
||||
int alloc_gamepad(input_t &input, int nr, rumble_queue_t &&rumble_queue);
|
||||
void free_gamepad(input_t &input, int nr);
|
||||
|
||||
#define SERVICE_NAME "Sunshine"
|
||||
|
|
|
|||
|
|
@ -18,11 +18,18 @@ constexpr touch_port_t target_touch_port {
|
|||
65535, 65535
|
||||
};
|
||||
|
||||
using client_t = util::safe_ptr<_VIGEM_CLIENT_T, vigem_free>;
|
||||
using target_t = util::safe_ptr<_VIGEM_TARGET_T, vigem_target_free>;
|
||||
|
||||
void CALLBACK x360_notify(
|
||||
client_t::pointer client,
|
||||
target_t::pointer target,
|
||||
std::uint8_t largeMotor, std::uint8_t smallMotor,
|
||||
std::uint8_t /* led_number */,
|
||||
void *userdata);
|
||||
|
||||
class vigem_t {
|
||||
public:
|
||||
using client_t = util::safe_ptr<_VIGEM_CLIENT_T, vigem_free>;
|
||||
using target_t = util::safe_ptr<_VIGEM_TARGET_T, vigem_target_free>;
|
||||
|
||||
int init() {
|
||||
VIGEM_ERROR status;
|
||||
|
||||
|
|
@ -40,8 +47,8 @@ public:
|
|||
return 0;
|
||||
}
|
||||
|
||||
int alloc_x360(int nr) {
|
||||
auto &x360 = x360s[nr];
|
||||
int alloc_x360(int nr, rumble_queue_t &rumble_queue) {
|
||||
auto &[rumble, x360] = x360s[nr];
|
||||
assert(!x360);
|
||||
|
||||
x360.reset(vigem_target_x360_alloc());
|
||||
|
|
@ -52,11 +59,18 @@ public:
|
|||
return -1;
|
||||
}
|
||||
|
||||
rumble = std::move(rumble_queue);
|
||||
|
||||
status = vigem_target_x360_register_notification(client.get(), x360.get(), x360_notify, this);
|
||||
if(!VIGEM_SUCCESS(status)) {
|
||||
BOOST_LOG(warning) << "Couldn't register notifications for rumble support ["sv << util::hex(status).to_string_view() << ']';
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void free_target(int nr) {
|
||||
auto &x360 = x360s[nr];
|
||||
auto &[_, x360] = x360s[nr];
|
||||
|
||||
if(x360 && vigem_target_is_attached(x360.get())) {
|
||||
auto status = vigem_target_remove(client.get(), x360.get());
|
||||
|
|
@ -68,9 +82,21 @@ public:
|
|||
x360.reset();
|
||||
}
|
||||
|
||||
void rumble(target_t::pointer target, std::uint8_t largeMotor, std::uint8_t smallMotor) {
|
||||
for(int x = 0; x < x360s.size(); ++x) {
|
||||
auto &[rumble_queue, x360] = x360s[x];
|
||||
|
||||
if(x360.get() == target) {
|
||||
rumble_queue->raise(x, largeMotor, smallMotor);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
~vigem_t() {
|
||||
if(client) {
|
||||
for(auto &x360 : x360s) {
|
||||
for(auto &[_, x360] : x360s) {
|
||||
if(x360 && vigem_target_is_attached(x360.get())) {
|
||||
auto status = vigem_target_remove(client.get(), x360.get());
|
||||
if(!VIGEM_SUCCESS(status)) {
|
||||
|
|
@ -83,10 +109,25 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
std::vector<target_t> x360s;
|
||||
std::vector<std::pair<rumble_queue_t, target_t>> x360s;
|
||||
|
||||
client_t client;
|
||||
};
|
||||
|
||||
void CALLBACK x360_notify(
|
||||
client_t::pointer client,
|
||||
target_t::pointer target,
|
||||
std::uint8_t largeMotor, std::uint8_t smallMotor,
|
||||
std::uint8_t /* led_number */,
|
||||
void *userdata) {
|
||||
|
||||
BOOST_LOG(debug)
|
||||
<< "largeMotor: "sv << (int)largeMotor << std::endl
|
||||
<< "smallMotor: "sv << (int)smallMotor;
|
||||
|
||||
task_pool.push(&vigem_t::rumble, (vigem_t *)userdata, target, largeMotor, smallMotor);
|
||||
}
|
||||
|
||||
input_t input() {
|
||||
input_t result { new vigem_t {} };
|
||||
|
||||
|
|
@ -110,6 +151,7 @@ retry:
|
|||
BOOST_LOG(error) << "Couldn't send input"sv;
|
||||
}
|
||||
}
|
||||
|
||||
void abs_mouse(input_t &input, const touch_port_t &touch_port, float x, float y) {
|
||||
INPUT i {};
|
||||
|
||||
|
|
@ -242,12 +284,12 @@ void keyboard(input_t &input, uint16_t modcode, bool release) {
|
|||
send_input(i);
|
||||
}
|
||||
|
||||
int alloc_gamepad(input_t &input, int nr) {
|
||||
int alloc_gamepad(input_t &input, int nr, rumble_queue_t &&rumble_queue) {
|
||||
if(!input) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ((vigem_t *)input.get())->alloc_x360(nr);
|
||||
return ((vigem_t *)input.get())->alloc_x360(nr, rumble_queue);
|
||||
}
|
||||
|
||||
void free_gamepad(input_t &input, int nr) {
|
||||
|
|
@ -257,6 +299,7 @@ void free_gamepad(input_t &input, int nr) {
|
|||
|
||||
((vigem_t *)input.get())->free_target(nr);
|
||||
}
|
||||
|
||||
void gamepad(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
|
||||
// If there is no gamepad support
|
||||
if(!input) {
|
||||
|
|
@ -265,8 +308,8 @@ void gamepad(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
|
|||
|
||||
auto vigem = (vigem_t *)input.get();
|
||||
|
||||
auto &xusb = *(PXUSB_REPORT)&gamepad_state;
|
||||
auto &x360 = vigem->x360s[nr];
|
||||
auto &xusb = *(PXUSB_REPORT)&gamepad_state;
|
||||
auto &[_, x360] = vigem->x360s[nr];
|
||||
|
||||
auto status = vigem_target_x360_update(vigem->client.get(), x360.get(), xusb);
|
||||
if(!VIGEM_SUCCESS(status)) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ struct ctx_t {
|
|||
|
||||
class proc_t {
|
||||
public:
|
||||
KITTY_DEFAULT_CONSTR_THROW(proc_t)
|
||||
KITTY_DEFAULT_CONSTR_MOVE_THROW(proc_t)
|
||||
|
||||
proc_t(
|
||||
boost::process::environment &&env,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,16 @@ struct control_terminate_t {
|
|||
std::uint32_t ec;
|
||||
};
|
||||
|
||||
struct control_rumble_t {
|
||||
control_header_v2 header;
|
||||
|
||||
std::uint32_t useless;
|
||||
|
||||
std::uint16_t id;
|
||||
std::uint16_t lowfreq;
|
||||
std::uint16_t highfreq;
|
||||
};
|
||||
|
||||
typedef struct control_encrypted_t {
|
||||
std::uint16_t encryptedHeaderType; // Always LE 0x0001
|
||||
std::uint16_t length; // sizeof(seq) + 16 byte tag + secondary header and data
|
||||
|
|
@ -281,6 +291,8 @@ struct session_t {
|
|||
|
||||
net::peer_t peer;
|
||||
std::uint8_t seq;
|
||||
|
||||
platf::rumble_queue_t rumble_queue;
|
||||
} control;
|
||||
|
||||
safe::mail_raw_t::event_t<bool> shutdown_event;
|
||||
|
|
@ -318,9 +330,13 @@ static inline std::string_view encode_control(session_t *session, const std::str
|
|||
return {};
|
||||
}
|
||||
|
||||
packet->seq = util::endian::little(seq);
|
||||
std::uint16_t packet_length = bytes + crypto::cipher::tag_size + sizeof(control_encrypted_t::seq);
|
||||
|
||||
return std::string_view { (char *)tagged_cipher.data(), (std::size_t)bytes };
|
||||
packet->encryptedHeaderType = util::endian::little(0x0001);
|
||||
packet->length = util::endian::little(packet_length);
|
||||
packet->seq = util::endian::little(seq);
|
||||
|
||||
return std::string_view { (char *)tagged_cipher.data(), packet_length + sizeof(control_encrypted_t) - sizeof(control_encrypted_t::seq) };
|
||||
}
|
||||
|
||||
int start_broadcast(broadcast_ctx_t &ctx);
|
||||
|
|
@ -537,6 +553,38 @@ std::vector<uint8_t> replace(const std::string_view &original, const std::string
|
|||
return replaced;
|
||||
}
|
||||
|
||||
int send_rumble(session_t *session, std::uint16_t id, std::uint16_t lowfreq, std::uint16_t highfreq) {
|
||||
if(!session->control.peer) {
|
||||
BOOST_LOG(warning) << "Couldn't send rumble data, still waiting for PING from Moonlight"sv;
|
||||
// Still waiting for PING from Moonlight
|
||||
return -1;
|
||||
}
|
||||
|
||||
control_rumble_t plaintext;
|
||||
plaintext.header.type = packetTypes[IDX_RUMBLE_DATA];
|
||||
plaintext.header.payloadLength = sizeof(control_rumble_t) - sizeof(control_header_v2);
|
||||
|
||||
plaintext.useless = 0xC0FFEE;
|
||||
plaintext.id = util::endian::little(id);
|
||||
plaintext.lowfreq = util::endian::little(lowfreq << 8);
|
||||
plaintext.highfreq = util::endian::little(highfreq << 8);
|
||||
|
||||
BOOST_LOG(fatal) << util::hex(plaintext.id).to_string_view() << " :: "sv << util::hex(plaintext.lowfreq).to_string_view() << " :: "sv << util::hex(plaintext.highfreq).to_string_view();
|
||||
std::array<std::uint8_t,
|
||||
sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(sizeof(plaintext)) + crypto::cipher::tag_size>
|
||||
encrypted_payload;
|
||||
|
||||
auto payload = encode_control(session, util::view(plaintext), encrypted_payload);
|
||||
if(session->broadcast_ref->control_server.send(payload, session->control.peer)) {
|
||||
TUPLE_2D(port, addr, platf::from_sockaddr_ex((sockaddr *)&session->control.peer->address.address));
|
||||
BOOST_LOG(warning) << "Couldn't send termination code to ["sv << addr << ':' << port << ']';
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void controlBroadcastThread(control_server_t *server) {
|
||||
server->map(packetTypes[IDX_PERIODIC_PING], [](session_t *session, const std::string_view &payload) {
|
||||
BOOST_LOG(verbose) << "type [IDX_START_A]"sv;
|
||||
|
|
@ -696,6 +744,13 @@ void controlBroadcastThread(control_server_t *server) {
|
|||
continue;
|
||||
}
|
||||
|
||||
auto &rumble_queue = session->control.rumble_queue;
|
||||
while(rumble_queue->peek()) {
|
||||
auto rumble = rumble_queue->pop();
|
||||
|
||||
send_rumble(session, rumble->id, rumble->lowfreq, rumble->highfreq);
|
||||
}
|
||||
|
||||
++pos;
|
||||
})
|
||||
}
|
||||
|
|
@ -706,7 +761,7 @@ void controlBroadcastThread(control_server_t *server) {
|
|||
break;
|
||||
}
|
||||
|
||||
server->iterate(500ms);
|
||||
server->iterate(50ms);
|
||||
}
|
||||
|
||||
// Let all remaining connections know the server is shutting down
|
||||
|
|
@ -722,10 +777,6 @@ void controlBroadcastThread(control_server_t *server) {
|
|||
sizeof(control_encrypted_t) + crypto::cipher::round_to_pkcs7_padded(sizeof(plaintext)) + crypto::cipher::tag_size>
|
||||
encrypted_payload;
|
||||
|
||||
auto packet = (control_encrypted_p)encrypted_payload.data();
|
||||
packet->encryptedHeaderType = util::endian::little(0x0001);
|
||||
packet->length = encrypted_payload.size() - sizeof(control_encrypted_t) + 4;
|
||||
|
||||
auto lg = server->_map_addr_session.lock();
|
||||
for(auto pos = std::begin(*server->_map_addr_session); pos != std::end(*server->_map_addr_session); ++pos) {
|
||||
auto session = pos->second.second;
|
||||
|
|
@ -1353,8 +1404,9 @@ std::shared_ptr<session_t> alloc(config_t &config, crypto::aes_t &gcm_key, crypt
|
|||
|
||||
session->config = config;
|
||||
|
||||
session->control.iv = iv;
|
||||
session->control.cipher = crypto::cipher::gcm_t {
|
||||
session->control.rumble_queue = mail->queue<platf::rumble_t>(mail::rumble);
|
||||
session->control.iv = iv;
|
||||
session->control.cipher = crypto::cipher::gcm_t {
|
||||
gcm_key, false
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,16 +56,21 @@ struct argument_type<T(U)> { typedef U type; };
|
|||
x &operator=(x &&) noexcept = default; \
|
||||
x();
|
||||
|
||||
#define KITTY_DEFAULT_CONSTR(x) \
|
||||
#define KITTY_DEFAULT_CONSTR_MOVE(x) \
|
||||
x(x &&) noexcept = default; \
|
||||
x &operator=(x &&) noexcept = default; \
|
||||
x() = default;
|
||||
|
||||
#define KITTY_DEFAULT_CONSTR_THROW(x) \
|
||||
x(x &&) = default; \
|
||||
x &operator=(x &&) = default; \
|
||||
#define KITTY_DEFAULT_CONSTR_MOVE_THROW(x) \
|
||||
x(x &&) = default; \
|
||||
x &operator=(x &&) = default; \
|
||||
x() = default;
|
||||
|
||||
#define KITTY_DEFAULT_CONSTR(x) \
|
||||
KITTY_DEFAULT_CONSTR_MOVE(x) \
|
||||
x(const x &) noexcept = default; \
|
||||
x &operator=(const x &) = default;
|
||||
|
||||
#define TUPLE_2D(a, b, expr) \
|
||||
decltype(expr) a##_##b = expr; \
|
||||
auto &a = std::get<0>(a##_##b); \
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ struct encoder_t {
|
|||
}
|
||||
|
||||
struct option_t {
|
||||
KITTY_DEFAULT_CONSTR(option_t)
|
||||
KITTY_DEFAULT_CONSTR_MOVE(option_t)
|
||||
option_t(const option_t &) = default;
|
||||
|
||||
std::string name;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ struct packet_raw_t : public AVPacket {
|
|||
std::string_view old;
|
||||
std::string_view _new;
|
||||
|
||||
KITTY_DEFAULT_CONSTR(replace_t)
|
||||
KITTY_DEFAULT_CONSTR_MOVE(replace_t)
|
||||
|
||||
replace_t(std::string_view old, std::string_view _new) noexcept : old { std::move(old) }, _new { std::move(_new) } {}
|
||||
};
|
||||
|
|
|
|||
2
third-party/ViGEmClient
vendored
2
third-party/ViGEmClient
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 52682b59c458388a74afbc3d7ef23de21983a86f
|
||||
Subproject commit f719a1d9eb51969a685a9213d9db6dbb801404c1
|
||||
Loading…
Add table
Add a link
Reference in a new issue