#include "controllers_panel.hpp" #include #include "ui/app_chrome.hpp" namespace coop { namespace { const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f); const ImVec4 kGrey(0.7f, 0.7f, 0.7f, 1.0f); struct ButtonBit { std::uint16_t mask; const char* label; }; // XINPUT_GAMEPAD_* bit values (kept local so this file needn't include Xinput.h). constexpr ButtonBit kButtons[] = { {0x0001, "Up"}, {0x0002, "Down"}, {0x0004, "Left"}, {0x0008, "Right"}, {0x0010, "Start"}, {0x0020, "Back"}, {0x0040, "LS"}, {0x0080, "RS"}, {0x0100, "LB"}, {0x0200, "RB"}, {0x1000, "A"}, {0x2000, "B"}, {0x4000, "X"}, {0x8000, "Y"}, }; void draw_pad(int index, const PadInfo& pad, bool debug_details) { ImGui::PushID(index); if (!pad.connected) { ImGui::TextDisabled("Slot %d: disconnected", index); ImGui::PopID(); return; } ImGui::TextColored(kGreen, "Slot %d [%s]", index, pad.source.c_str()); if (debug_details) { // Triggers on the slot line (saves a row); thumbsticks below. ImGui::SameLine(); ImGui::TextDisabled("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger); } bool first = true; ImGui::TextUnformatted("Buttons: "); for (const ButtonBit& b : kButtons) { if ((pad.state.buttons & b.mask) != 0) { ImGui::SameLine(); ImGui::TextColored(kGreen, "%s%s", first ? "" : ", ", b.label); first = false; } } if (first) { ImGui::SameLine(); ImGui::TextDisabled("(none)"); } if (debug_details) { ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly, pad.state.thumb_rx, pad.state.thumb_ry); } ImGui::Separator(); ImGui::PopID(); } } // namespace 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.backend); #ifdef COOP_WITH_STEAM ImGui::Checkbox("Use Steam Input (experimental)", &steam_requested_); if (steam_requested_) { ImGui::SameLine(); ImGui::TextColored(steam_active_ ? kGreen : kGrey, steam_active_ ? "(active)" : "(starting...)"); ImGui::TextDisabled("Needs a controller bound to Steam Input for this app; otherwise"); ImGui::TextDisabled("XInput is hidden and no input arrives. Leave off for plain XInput."); } if (steam_note_[0] != '\0') { ImGui::TextColored(kGrey, "%s", steam_note_); } #endif // Synthetic test input (debug aid): drives the game with a non-human pattern so // forwarding can be proven without a real controller. Only meaningful once the // XInput hook is attached. if (debug_details) { ImGui::BeginDisabled(!status.attached); ImGui::Checkbox("Forward synthetic test input", &test_input_); ImGui::EndDisabled(); if (test_input_) { ImGui::SameLine(); ImGui::TextDisabled("(ignores your controller)"); } } // --- Guest pads the host receives from RPT ----------------------------- ImGui::SeparatorText("Incoming (host receives)"); const auto& pads = input.pads; for (int i = 0; i < static_cast(pads.size()); ++i) { draw_pad(i, pads[i], debug_details); } // --- What the injected game reads back via the XInput hook -------------- ImGui::SeparatorText("Game polling (hook reports)"); if (!status.attached) { ImGui::TextDisabled("Not injected (no XInput hook)."); record_panel_fit("Controllers"); ImGui::End(); return; } // Convert the cumulative per-slot counters into rates every half second. const double now = ImGui::GetTime(); if (now - last_sample_time_ >= 0.5) { const double dt = now - last_sample_time_; for (int i = 0; i < static_cast(kMaxPads); ++i) { const unsigned long long delta = status.get_state[i] >= last_state_count_[i] ? status.get_state[i] - last_state_count_[i] : 0; state_rate_[i] = dt > 0.0 ? static_cast(delta) / dt : 0.0; last_state_count_[i] = status.get_state[i]; } last_sample_time_ = now; } double total_rate = 0.0; for (int i = 0; i < static_cast(kMaxPads); ++i) { total_rate += state_rate_[i]; } if (total_rate > 0.0) { ImGui::TextColored(kGreen, "Game reading controller: %5.0f polls/s", total_rate); } else { ImGui::TextColored(kGrey, "Game reading controller: idle"); } // One per-slot table covers both the hook's poll counters and the input round-trip // (what we forwarded vs what the game read back through the hook). A round-trip mismatch // isolates a tool->game forwarding problem from an input->tool one. Merged into a single // table so the (debug) controller view stays inside its panel even with every slot busy. if (debug_details && ImGui::BeginTable("slots", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Slot"); ImGui::TableSetupColumn("Poll/s"); ImGui::TableSetupColumn("Polls"); ImGui::TableSetupColumn("Forwarded btn/LX,LY"); ImGui::TableSetupColumn("Game read btn/LX,LY"); ImGui::TableHeadersRow(); const auto& fwd = input.pads; for (int i = 0; i < static_cast(kMaxPads); ++i) { const CoopPadState& f = fwd[i].state; const CoopPadState& r = status.read_state[i]; ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::Text("%d", i); ImGui::TableNextColumn(); if (state_rate_[i] > 0.0) { ImGui::TextColored(kGreen, "%5.0f", state_rate_[i]); } else { ImGui::TextDisabled("0"); } ImGui::TableNextColumn(); ImGui::Text("%llu", static_cast(status.get_state[i])); ImGui::TableNextColumn(); 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 %6d,%6d", r.buttons, r.thumb_lx, r.thumb_ly); } ImGui::EndTable(); } record_panel_fit("Controllers"); ImGui::End(); } } // namespace coop