diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 30ef917..545c2af 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(coop_host WIN32 src/log_panel.cpp src/ui/app_chrome.cpp src/input/xinput_source.cpp + src/input/input_worker.cpp src/inject/process_list.cpp src/inject/window_list.cpp src/inject/injector.cpp diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index be6eac9..140ad8c 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -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 { diff --git a/host/src/capture_panel.cpp b/host/src/capture_panel.cpp index a5f6e53..180edd5 100644 --- a/host/src/capture_panel.cpp +++ b/host/src/capture_panel.cpp @@ -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(); diff --git a/host/src/controllers_panel.cpp b/host/src/controllers_panel.cpp index ce0e07f..1d53064 100644 --- a/host/src/controllers_panel.cpp +++ b/host/src/controllers_panel.cpp @@ -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(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(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(); } diff --git a/host/src/controllers_panel.hpp b/host/src/controllers_panel.hpp index 2c4bd3f..63aa801 100644 --- a/host/src/controllers_panel.hpp +++ b/host/src/controllers_panel.hpp @@ -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. diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index e46e5fc..e2d6895 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -256,7 +256,7 @@ void InjectionPanel::reattach() void InjectionPanel::publish(const std::array& pads) { - if (!test_input_) + if (!test_input_.load(std::memory_order_relaxed)) { server_.publish(pads); return; diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index d244612..b41505f 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -3,6 +3,7 @@ #pragma once #include +#include #include #include @@ -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 test_input_{false}; // Host-requested per-subsystem install state (default on). Written to the hook // over the control channel; the hook reconciles each tick. diff --git a/host/src/input/input_source.hpp b/host/src/input/input_source.hpp index f26228b..157ea13 100644 --- a/host/src/input/input_source.hpp +++ b/host/src/input/input_source.hpp @@ -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 pads{}; + const char* backend = "XInput"; // backend name (static string literal; thread-safe to share) + bool steam_active = false; +}; + class InputSource { public: diff --git a/host/src/input/input_worker.cpp b/host/src/input/input_worker.cpp new file mode 100644 index 0000000..3c29f25 --- /dev/null +++ b/host/src/input/input_worker.cpp @@ -0,0 +1,144 @@ +#include "input/input_worker.hpp" + +#include +#include +#include + +#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 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(); + 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(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 diff --git a/host/src/input/input_worker.hpp b/host/src/input/input_worker.hpp new file mode 100644 index 0000000..2c365b8 --- /dev/null +++ b/host/src/input/input_worker.hpp @@ -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 +#include +#include +#include + +#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 running_{false}; + std::atomic want_steam_{false}; + std::atomic steam_failed_{false}; + InjectionPanel* injection_ = nullptr; + std::string steam_manifest_; + + mutable std::mutex snapshot_mutex_; + InputSnapshot snapshot_; +}; + +} // namespace coop diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 19f7674..fb14ab5 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -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& pads) { + std::scoped_lock lock(mutex_); if (block_ == nullptr) { return; @@ -51,6 +53,7 @@ void IpcServer::publish(const std::array& 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) { diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 30b4475..a1db637 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -4,6 +4,7 @@ #include #include +#include #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; diff --git a/host/src/main.cpp b/host/src/main.cpp index 6771930..657aa89 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -6,7 +6,6 @@ // this window via Windows Graphics Capture (Video mirror panel). #include -#include #include @@ -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 - -#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 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(); - 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(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) { diff --git a/host/src/ui/app_chrome.cpp b/host/src/ui/app_chrome.cpp index c3f57af..daf51a7 100644 --- a/host/src/ui/app_chrome.cpp +++ b/host/src/ui/app_chrome.cpp @@ -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);