Move input polling to its own thread; fixed-width fast-changing UI numbers
Input thread: controller polling, pad publishing, and rumble forwarding were driven by the render loop, so a low/synced frame rate throttled how often guest input reached the game. New InputWorker owns the InputSource and runs poll + IPC publish + rumble on a dedicated ~1 kHz thread, independent of rendering. The UI thread reads a copy-safe InputSnapshot for the Controllers panel and relays the Steam-Input request/active/failed state to/from the worker (Steam init/shutdown now happen on the worker thread). IpcServer gained a mutex so the worker's publish() / hook_status() can't race the UI thread starting/stopping the shared-memory channel (use-after-unmap); InjectionPanel::test_input_ is now atomic. ControllersPanel::draw takes an InputSnapshot instead of the live InputSource. Fixed-width numbers: fast-changing readouts (menu-bar FPS/ms, Video pipeline rates + latency + graph legend, controller poll rates + round-trip sticks, audio buffered ms + frames/s) printed with %.0f etc., so they shifted/blurred as values crossed digit thresholds (99 -> 100) each frame. Padded them to fixed field widths so they stay put. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s",
|
||||
mirror_.source_name());
|
||||
ImGui::Text("Buffered: %u ms", mirror_.buffered_ms());
|
||||
ImGui::Text("Buffered: %4u ms", mirror_.buffered_ms());
|
||||
}
|
||||
const std::string mirror_status = mirror_.status();
|
||||
if (!mirror_status.empty())
|
||||
@@ -176,7 +176,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
|
||||
{
|
||||
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "live");
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("%.0f/s", frames_per_s_[i]);
|
||||
ImGui::TextDisabled("%6.0f/s", frames_per_s_[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -188,13 +188,15 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
|
||||
}
|
||||
const double now = ImGui::GetTime();
|
||||
ImGui::SeparatorText("Pipeline rates");
|
||||
ImGui::Text("Tool render: %.0f FPS (%.2f ms)", stats.fps(), stats.avg_ms());
|
||||
// Fixed field widths so fast-changing numbers don't jitter as they cross digit
|
||||
// thresholds (e.g. 99 -> 100) frame to frame.
|
||||
ImGui::Text("Tool render: %4.0f FPS (%6.2f ms)", stats.fps(), stats.avg_ms());
|
||||
|
||||
if (source_ == Source_Hooked)
|
||||
{
|
||||
const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{};
|
||||
ImGui::Text("Game present: %.0f /s", present_rate_.sample(v.present_calls, now));
|
||||
ImGui::Text("Hook publish: %.0f /s", capture_rate_.sample(v.generation, now));
|
||||
ImGui::Text("Game present: %5.0f /s", present_rate_.sample(v.present_calls, now));
|
||||
ImGui::Text("Hook publish: %5.0f /s", capture_rate_.sample(v.generation, now));
|
||||
|
||||
// On each newly published frame, measure now - present_qpc (system-wide clock).
|
||||
if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0)
|
||||
@@ -234,7 +236,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
|
||||
}
|
||||
if (lat_avg_ > 0.0f)
|
||||
{
|
||||
ImGui::Text("Capture->display: avg %.1f min %.1f max %.1f ms", lat_avg_, lat_min_, lat_max_);
|
||||
ImGui::Text("Capture->display: avg %6.1f min %6.1f max %6.1f ms", lat_avg_, lat_min_, lat_max_);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -244,7 +246,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
|
||||
else
|
||||
{
|
||||
ImGui::TextDisabled("Game present: n/a (WGC has no game frame timing)");
|
||||
ImGui::Text("WGC capture: %.0f /s", capture_rate_.sample(capture_.frames_arrived(), now));
|
||||
ImGui::Text("WGC capture: %5.0f /s", capture_rate_.sample(capture_.frames_arrived(), now));
|
||||
ImGui::TextDisabled("Latency: n/a (WGC frames aren't game-timestamped)");
|
||||
}
|
||||
}
|
||||
@@ -319,7 +321,7 @@ void CapturePanel::draw_perf_graphs(const FrameStats& /*stats*/)
|
||||
// Legend: a colored label + the latest value of each line, so colors map to series.
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
ImGui::TextColored(cols[i], "%s %.0f", names[i], counts[i] > 0 ? fps[i][counts[i] - 1] : 0.0f);
|
||||
ImGui::TextColored(cols[i], "%-4s %4.0f", names[i], counts[i] > 0 ? fps[i][counts[i] - 1] : 0.0f);
|
||||
if (i + 1 < n)
|
||||
{
|
||||
ImGui::SameLine();
|
||||
|
||||
@@ -67,12 +67,12 @@ void draw_pad(int index, const PadInfo& pad, bool debug_details)
|
||||
|
||||
} // namespace
|
||||
|
||||
void ControllersPanel::draw(const InputSource& input, const HookStatusView& status, bool debug_details)
|
||||
void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& status, bool debug_details)
|
||||
{
|
||||
apply_panel_layout(Panel::Controllers);
|
||||
ImGui::Begin("Controllers");
|
||||
|
||||
ImGui::Text("Input backend: %s", input.name());
|
||||
ImGui::Text("Input backend: %s", input.backend);
|
||||
ImGui::TextDisabled("This window is what Remote Play Together captures.");
|
||||
ImGui::TextDisabled("F1: hide overlay (clean mirror) Esc: quit");
|
||||
|
||||
@@ -108,7 +108,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
||||
|
||||
// --- Guest pads the host receives from RPT -----------------------------
|
||||
ImGui::SeparatorText("Incoming (host receives)");
|
||||
const auto& pads = input.pads();
|
||||
const auto& pads = input.pads;
|
||||
for (int i = 0; i < static_cast<int>(pads.size()); ++i)
|
||||
{
|
||||
draw_pad(i, pads[i], debug_details);
|
||||
@@ -145,7 +145,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
||||
}
|
||||
if (total_rate > 0.0)
|
||||
{
|
||||
ImGui::TextColored(kGreen, "Game reading controller: %.0f polls/s", total_rate);
|
||||
ImGui::TextColored(kGreen, "Game reading controller: %5.0f polls/s", total_rate);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -168,7 +168,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
||||
ImGui::TableNextColumn();
|
||||
if (state_rate_[i] > 0.0)
|
||||
{
|
||||
ImGui::TextColored(kGreen, "%.0f", state_rate_[i]);
|
||||
ImGui::TextColored(kGreen, "%5.0f", state_rate_[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,7 +194,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
||||
ImGui::TableSetupColumn("Forwarded btn / LX,LY");
|
||||
ImGui::TableSetupColumn("Game read btn / LX,LY");
|
||||
ImGui::TableHeadersRow();
|
||||
const auto& fwd = input.pads();
|
||||
const auto& fwd = input.pads;
|
||||
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
|
||||
{
|
||||
const CoopPadState& f = fwd[i].state;
|
||||
@@ -203,10 +203,10 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%d", i);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("0x%04X %d,%d", f.buttons, f.thumb_lx, f.thumb_ly);
|
||||
ImGui::Text("0x%04X %6d,%6d", f.buttons, f.thumb_lx, f.thumb_ly);
|
||||
ImGui::TableNextColumn();
|
||||
const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly;
|
||||
ImGui::TextColored(match ? kGreen : kGrey, "0x%04X %d,%d", r.buttons, r.thumb_lx, r.thumb_ly);
|
||||
ImGui::TextColored(match ? kGreen : kGrey, "0x%04X %6d,%6d", r.buttons, r.thumb_lx, r.thumb_ly);
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@ namespace coop
|
||||
class ControllersPanel
|
||||
{
|
||||
public:
|
||||
// `input` is the input worker's latest snapshot (guest pads + active backend);
|
||||
// `status` is the hook's back-channel (per-slot poll counters); `debug_details`
|
||||
// reveals the raw axis values, the per-slot poll-rate table, and the synthetic
|
||||
// test-input toggle.
|
||||
void draw(const InputSource& input, const HookStatusView& status, bool debug_details);
|
||||
void draw(const InputSnapshot& input, const HookStatusView& status, bool debug_details);
|
||||
|
||||
// Whether the operator enabled "Forward synthetic test input" (a controller debug
|
||||
// aid). The host feeds this to InjectionPanel, which substitutes a synthetic pad.
|
||||
|
||||
@@ -256,7 +256,7 @@ void InjectionPanel::reattach()
|
||||
|
||||
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
|
||||
{
|
||||
if (!test_input_)
|
||||
if (!test_input_.load(std::memory_order_relaxed))
|
||||
{
|
||||
server_.publish(pads);
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -52,7 +53,7 @@ public:
|
||||
// panel (a controller-debug aid); the host feeds its state here each frame.
|
||||
void set_test_input(bool on)
|
||||
{
|
||||
test_input_ = on;
|
||||
test_input_.store(on, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// The injected game's main window, as reported by the hook (null if none). A
|
||||
@@ -165,7 +166,9 @@ private:
|
||||
std::string status_;
|
||||
ImVec4 status_color_;
|
||||
|
||||
bool test_input_ = false;
|
||||
// Written by the UI thread (set_test_input), read by the input worker thread
|
||||
// (publish) -- atomic so that cross-thread read is well-defined.
|
||||
std::atomic<bool> test_input_{false};
|
||||
|
||||
// Host-requested per-subsystem install state (default on). Written to the hook
|
||||
// over the control channel; the hook reconciles each tick.
|
||||
|
||||
@@ -21,6 +21,16 @@ struct PadInfo
|
||||
std::string source; // human-readable label for the debug overlay
|
||||
};
|
||||
|
||||
// A copy-safe snapshot of the input backend's state, published by the input worker
|
||||
// thread for the UI to display. This decouples the Controllers panel (UI thread) from
|
||||
// the worker's live polling, so the worker can own its InputSource exclusively.
|
||||
struct InputSnapshot
|
||||
{
|
||||
std::array<PadInfo, kMaxPads> pads{};
|
||||
const char* backend = "XInput"; // backend name (static string literal; thread-safe to share)
|
||||
bool steam_active = false;
|
||||
};
|
||||
|
||||
class InputSource
|
||||
{
|
||||
public:
|
||||
|
||||
144
host/src/input/input_worker.cpp
Normal file
144
host/src/input/input_worker.cpp
Normal file
@@ -0,0 +1,144 @@
|
||||
#include "input/input_worker.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
#include "injection_panel.hpp"
|
||||
#include "input/xinput_source.hpp"
|
||||
|
||||
#ifdef COOP_WITH_STEAM
|
||||
#include "input/steam_input_source.hpp"
|
||||
#endif
|
||||
|
||||
namespace coop
|
||||
{
|
||||
|
||||
InputWorker::~InputWorker()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
void InputWorker::start(InjectionPanel* injection, std::string steam_manifest)
|
||||
{
|
||||
if (running_.load(std::memory_order_acquire))
|
||||
{
|
||||
return;
|
||||
}
|
||||
injection_ = injection;
|
||||
steam_manifest_ = std::move(steam_manifest);
|
||||
running_.store(true, std::memory_order_release);
|
||||
thread_ = std::thread([this] { run(); });
|
||||
}
|
||||
|
||||
void InputWorker::stop()
|
||||
{
|
||||
running_.store(false, std::memory_order_release);
|
||||
if (thread_.joinable())
|
||||
{
|
||||
thread_.join();
|
||||
}
|
||||
}
|
||||
|
||||
InputSnapshot InputWorker::snapshot() const
|
||||
{
|
||||
std::scoped_lock lock(snapshot_mutex_);
|
||||
return snapshot_;
|
||||
}
|
||||
|
||||
void InputWorker::publish_snapshot(const InputSource& src, bool steam_active)
|
||||
{
|
||||
std::scoped_lock lock(snapshot_mutex_);
|
||||
snapshot_.pads = src.pads();
|
||||
snapshot_.backend = src.name();
|
||||
snapshot_.steam_active = steam_active;
|
||||
}
|
||||
|
||||
void InputWorker::run()
|
||||
{
|
||||
// The worker owns its input backend(s) exclusively, so no locking is needed around
|
||||
// polling -- only the published snapshot and the IPC are shared with the UI thread.
|
||||
XInputSource xinput;
|
||||
#ifdef COOP_WITH_STEAM
|
||||
std::unique_ptr<SteamInputSource> steam;
|
||||
#endif
|
||||
InputSource* active = &xinput;
|
||||
|
||||
// Last rumble forwarded per slot, so we only re-send on change.
|
||||
std::uint16_t last_rumble_l[kMaxPads] = {};
|
||||
std::uint16_t last_rumble_r[kMaxPads] = {};
|
||||
|
||||
while (running_.load(std::memory_order_relaxed))
|
||||
{
|
||||
const bool want_steam = want_steam_.load(std::memory_order_relaxed);
|
||||
#ifdef COOP_WITH_STEAM
|
||||
// Reconcile the backend with the UI's request. Initializing Steam Input hijacks
|
||||
// XInput, so it's strictly opt-in; a failed init falls back to plain XInput and
|
||||
// flags steam_failed_ so the UI can reset its toggle (and a later retry is
|
||||
// possible once the request is cleared).
|
||||
if (want_steam && steam == nullptr && !steam_failed_.load(std::memory_order_relaxed))
|
||||
{
|
||||
steam = std::make_unique<SteamInputSource>();
|
||||
if (steam->init(steam_manifest_))
|
||||
{
|
||||
active = steam.get();
|
||||
}
|
||||
else
|
||||
{
|
||||
steam.reset();
|
||||
active = &xinput;
|
||||
steam_failed_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
else if (!want_steam && steam != nullptr)
|
||||
{
|
||||
steam->shutdown();
|
||||
steam.reset();
|
||||
active = &xinput;
|
||||
}
|
||||
if (!want_steam)
|
||||
{
|
||||
steam_failed_.store(false, std::memory_order_relaxed); // allow a future retry
|
||||
}
|
||||
const bool steam_active = steam != nullptr;
|
||||
#else
|
||||
(void)want_steam;
|
||||
const bool steam_active = false;
|
||||
#endif
|
||||
|
||||
active->poll();
|
||||
|
||||
if (injection_ != nullptr)
|
||||
{
|
||||
// Push the latest pads to the game (publish() substitutes synthetic test input
|
||||
// itself when that mode is on), then forward any newly requested rumble.
|
||||
injection_->publish(active->pads());
|
||||
|
||||
const HookStatusView hs = injection_->hook_status();
|
||||
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
|
||||
{
|
||||
if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i])
|
||||
{
|
||||
active->set_rumble(i, hs.rumble_left[i], hs.rumble_right[i]);
|
||||
last_rumble_l[i] = hs.rumble_left[i];
|
||||
last_rumble_r[i] = hs.rumble_right[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publish_snapshot(*active, steam_active);
|
||||
|
||||
// ~1 ms cadence (timeBeginPeriod(1) in wWinMain keeps Sleep granular), so input
|
||||
// is polled/forwarded at ~1 kHz regardless of the render frame rate.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
#ifdef COOP_WITH_STEAM
|
||||
if (steam != nullptr)
|
||||
{
|
||||
steam->shutdown();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace coop
|
||||
68
host/src/input/input_worker.hpp
Normal file
68
host/src/input/input_worker.hpp
Normal file
@@ -0,0 +1,68 @@
|
||||
// Runs controller polling + forwarding on its own thread, decoupled from the render
|
||||
// loop. The render frame rate (which can drop, especially with frame-sync) must not
|
||||
// throttle how often guest input is polled and pushed to the game, so this worker
|
||||
// owns the InputSource, polls it at a fixed high cadence, publishes pads to the
|
||||
// injected hook over IPC, and forwards game-requested rumble back to the guest pad.
|
||||
// The UI thread reads a copy-safe snapshot() for the Controllers panel.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "input/input_source.hpp"
|
||||
|
||||
namespace coop
|
||||
{
|
||||
|
||||
class InjectionPanel;
|
||||
|
||||
class InputWorker
|
||||
{
|
||||
public:
|
||||
InputWorker() = default;
|
||||
~InputWorker();
|
||||
|
||||
InputWorker(const InputWorker&) = delete;
|
||||
InputWorker& operator=(const InputWorker&) = delete;
|
||||
|
||||
// Start the input thread. `injection` receives published pads and supplies the
|
||||
// game's rumble requests; it must outlive this worker. `steam_manifest` is the
|
||||
// Steam Input action-manifest path, used only if/when Steam Input is enabled
|
||||
// (ignored in non-Steam builds). No-op if already running.
|
||||
void start(InjectionPanel* injection, std::string steam_manifest);
|
||||
void stop();
|
||||
|
||||
// UI -> worker: request the Steam Input backend (true) or plain XInput (false).
|
||||
void set_want_steam(bool on)
|
||||
{
|
||||
want_steam_.store(on, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// worker -> UI: latest snapshot for the Controllers panel (thread-safe copy).
|
||||
[[nodiscard]] InputSnapshot snapshot() const;
|
||||
|
||||
// worker -> UI: Steam Input was requested but failed to start (so the panel can
|
||||
// reset its toggle and fall back to XInput). Cleared once Steam is not requested.
|
||||
[[nodiscard]] bool steam_failed() const
|
||||
{
|
||||
return steam_failed_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
void run();
|
||||
void publish_snapshot(const InputSource& src, bool steam_active);
|
||||
|
||||
std::thread thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> want_steam_{false};
|
||||
std::atomic<bool> steam_failed_{false};
|
||||
InjectionPanel* injection_ = nullptr;
|
||||
std::string steam_manifest_;
|
||||
|
||||
mutable std::mutex snapshot_mutex_;
|
||||
InputSnapshot snapshot_;
|
||||
};
|
||||
|
||||
} // namespace coop
|
||||
@@ -5,7 +5,8 @@ namespace coop
|
||||
|
||||
bool IpcServer::start(unsigned long target_pid)
|
||||
{
|
||||
stop();
|
||||
std::scoped_lock lock(mutex_);
|
||||
stop_locked();
|
||||
|
||||
if (!shm_.create(shared_memory_name(target_pid), sizeof(SharedBlock)))
|
||||
{
|
||||
@@ -36,6 +37,7 @@ bool IpcServer::start(unsigned long target_pid)
|
||||
|
||||
void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (block_ == nullptr)
|
||||
{
|
||||
return;
|
||||
@@ -51,6 +53,7 @@ void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
|
||||
|
||||
HookStatusView IpcServer::hook_status() const
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
HookStatusView view;
|
||||
if (block_ == nullptr)
|
||||
{
|
||||
@@ -96,6 +99,7 @@ HookStatusView IpcServer::hook_status() const
|
||||
|
||||
VideoShareView IpcServer::video_share() const
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
VideoShareView v;
|
||||
if (block_ == nullptr)
|
||||
{
|
||||
@@ -113,6 +117,7 @@ VideoShareView IpcServer::video_share() const
|
||||
|
||||
void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (block_ != nullptr && subsystem < HookSubsys_Count)
|
||||
{
|
||||
// 0 = install, 1 = remove.
|
||||
@@ -121,6 +126,12 @@ void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
|
||||
}
|
||||
|
||||
void IpcServer::stop()
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
stop_locked();
|
||||
}
|
||||
|
||||
void IpcServer::stop_locked()
|
||||
{
|
||||
if (block_ != nullptr)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
#include "coop/log_ring.hpp"
|
||||
#include "coop/protocol.hpp"
|
||||
@@ -82,6 +83,7 @@ public:
|
||||
// silently if not started or the ring is full.
|
||||
void push_mkb(const MkbEvent& ev)
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (block_ != nullptr)
|
||||
{
|
||||
push_mkb_event(block_->mkb, ev);
|
||||
@@ -92,6 +94,7 @@ public:
|
||||
// (false, the default). No-op if not started.
|
||||
void set_cursor_clip_allowed(bool allowed)
|
||||
{
|
||||
std::scoped_lock lock(mutex_);
|
||||
if (block_ != nullptr)
|
||||
{
|
||||
block_->control.allow_cursor_clip.store(allowed ? 1u : 0u, std::memory_order_release);
|
||||
@@ -119,6 +122,14 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
void stop_locked(); // tear-down body shared by start()/stop(); caller holds mutex_
|
||||
|
||||
// Guards the mapping pointer (block_) and its accesses. The input worker thread
|
||||
// calls publish()/hook_status() while the UI thread may start()/stop() the channel
|
||||
// (swapping/unmapping block_), so those must be mutually exclusive to avoid a
|
||||
// use-after-unmap. Critical sections are tiny (a struct copy / memcpy).
|
||||
mutable std::mutex mutex_;
|
||||
|
||||
SharedMemory shm_;
|
||||
SharedBlock* block_ = nullptr;
|
||||
unsigned long target_pid_ = 0;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// this window via Windows Graphics Capture (Video mirror panel).
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
@@ -23,14 +22,12 @@
|
||||
#include "imgui_layer.hpp"
|
||||
#include "inject/mkb_forward.hpp"
|
||||
#include "injection_panel.hpp"
|
||||
#include "input/xinput_source.hpp"
|
||||
#include "input/input_worker.hpp"
|
||||
#include "log_panel.hpp"
|
||||
#include "ui/app_chrome.hpp"
|
||||
|
||||
#ifdef COOP_WITH_STEAM
|
||||
#include <string>
|
||||
|
||||
#include "input/steam_input_source.hpp"
|
||||
#endif
|
||||
|
||||
namespace
|
||||
@@ -125,17 +122,6 @@ int run()
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 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<coop::SteamInputSource> steam;
|
||||
#endif
|
||||
coop::ControllersPanel controllers;
|
||||
coop::InjectionPanel injection;
|
||||
coop::AudioPanel audio;
|
||||
@@ -148,6 +134,19 @@ int run()
|
||||
}
|
||||
capture.set_injection(&injection); // for the Present-hook (Hooked) video source
|
||||
|
||||
// Controller polling + forwarding runs on its own thread so the render frame rate
|
||||
// (which can drop, especially with frame-sync) never throttles input. XInput is the
|
||||
// default guest path (RPT delivers guest pads as XInput and it Just Works); Steam
|
||||
// Input is opt-in (Controllers panel) -- merely initializing it hijacks XInput and
|
||||
// hides controllers unless they're bound to our action set, so it can silently break
|
||||
// input. The worker reconciles the active backend with the toggle.
|
||||
coop::InputWorker input_worker;
|
||||
#ifdef COOP_WITH_STEAM
|
||||
input_worker.start(&injection, steam_manifest_path());
|
||||
#else
|
||||
input_worker.start(&injection, std::string());
|
||||
#endif
|
||||
|
||||
// The overlay can be hidden (F1) so the window is a clean mirror for Remote
|
||||
// Play Together; the pipelines keep running underneath either way.
|
||||
bool show_overlay = true;
|
||||
@@ -155,10 +154,6 @@ int run()
|
||||
coop::UiState ui;
|
||||
coop::FrameStats stats;
|
||||
|
||||
// Last rumble forwarded per slot, so we only re-send on change.
|
||||
std::uint16_t last_rumble_l[coop::kMaxPads] = {};
|
||||
std::uint16_t last_rumble_r[coop::kMaxPads] = {};
|
||||
|
||||
// Frame-sync: the hook generation we last presented (so we wait for the next one).
|
||||
std::uint32_t last_synced_gen = 0;
|
||||
|
||||
@@ -174,51 +169,24 @@ int run()
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Input polling, pad publishing, and rumble all run on the input worker thread;
|
||||
// here we only relay UI requests to it and read back its snapshot for display.
|
||||
const coop::InputSnapshot input_snapshot = input_worker.snapshot();
|
||||
#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)
|
||||
input_worker.set_want_steam(controllers.steam_input_requested());
|
||||
if (input_worker.steam_failed())
|
||||
{
|
||||
steam = std::make_unique<coop::SteamInputSource>();
|
||||
if (steam->init(steam_manifest_path()))
|
||||
{
|
||||
input = steam.get();
|
||||
controllers.set_steam_active(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
steam.reset();
|
||||
controllers.on_steam_init_failed();
|
||||
}
|
||||
controllers.on_steam_init_failed(); // resets the toggle; worker falls back to XInput
|
||||
}
|
||||
else if (!want_steam && steam != nullptr)
|
||||
else
|
||||
{
|
||||
steam->shutdown();
|
||||
steam.reset();
|
||||
input = &xinput;
|
||||
controllers.set_steam_active(false);
|
||||
controllers.set_steam_active(input_snapshot.steam_active);
|
||||
}
|
||||
#endif
|
||||
input->poll();
|
||||
injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel
|
||||
injection.publish(input->pads());
|
||||
injection.tick(); // refresh target liveness before the mirror panels read game_hwnd()
|
||||
|
||||
// Forward the rumble the game requested back to the guest's controller (only
|
||||
// when it changes, to avoid spamming XInputSetState / TriggerVibration).
|
||||
{
|
||||
const coop::HookStatusView hs = injection.hook_status();
|
||||
for (int i = 0; i < static_cast<int>(coop::kMaxPads); ++i)
|
||||
{
|
||||
if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i])
|
||||
{
|
||||
input->set_rumble(i, hs.rumble_left[i], hs.rumble_right[i]);
|
||||
last_rumble_l[i] = hs.rumble_left[i];
|
||||
last_rumble_r[i] = hs.rumble_right[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HWND game = injection.game_hwnd();
|
||||
capture.set_target(game);
|
||||
audio.set_target(game);
|
||||
@@ -245,7 +213,7 @@ int run()
|
||||
coop::draw_main_menu_bar(ui, stats);
|
||||
if (ui.show_controllers)
|
||||
{
|
||||
controllers.draw(*input, injection.hook_status(), ui.debug_details);
|
||||
controllers.draw(input_snapshot, injection.hook_status(), ui.debug_details);
|
||||
}
|
||||
if (ui.show_injection)
|
||||
{
|
||||
|
||||
@@ -145,7 +145,9 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats)
|
||||
// Right-aligned performance readout: stable FPS with the frame-time spread
|
||||
// (min/max over the last second) so stutter is visible at a glance.
|
||||
char perf[96];
|
||||
std::snprintf(perf, sizeof(perf), "%.0f FPS %.2f ms (%.2f-%.2f)", stats.fps(), stats.avg_ms(),
|
||||
// Fixed field widths so the readout doesn't jitter/blur as values cross digit
|
||||
// thresholds (e.g. 99 -> 100) each frame.
|
||||
std::snprintf(perf, sizeof(perf), "%4.0f FPS %6.2f ms (%6.2f-%6.2f)", stats.fps(), stats.avg_ms(),
|
||||
stats.min_ms(), stats.max_ms());
|
||||
const float text_w = ImGui::CalcTextSize(perf).x;
|
||||
ImGui::SameLine(ImGui::GetWindowWidth() - text_w - ImGui::GetStyle().FramePadding.x * 2.0f);
|
||||
|
||||
Reference in New Issue
Block a user