Files
CoopAllTheThings/host/src/controllers_panel.cpp
BlackMark 327ba1f394 Add per-backend input debug visualization + round-trip view
Makes it possible to isolate an input->tool problem from a tool->game one in the
Controllers panel (under Debug details):
- which backend fed each slot is already shown via the per-pad source tag
  ("XInput #0" / "Steam Input") in the Incoming section;
- a new "Round-trip" table shows, per slot, the state we forwarded (the active
  backend's pad) next to what the game actually read back, highlighting matches.

For the round-trip, the XInput hook now echoes the state it returns to the game into
the status (protocol v9->v10: per-slot read_state in HookStatus).

Verified: hook_selftest (x64 + x86) asserts the hook records the game-read state;
full build x64 + x86 clean; ctest x64 9/9, x86 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:15:07 +02:00

220 lines
6.4 KiB
C++

#include "controllers_panel.hpp"
#include <imgui.h>
#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());
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("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger);
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 InputSource& input, const HookStatusView& status, bool debug_details)
{
apply_panel_layout(Panel::Controllers);
ImGui::Begin("Controllers");
ImGui::Text("Input backend: %s", input.name());
ImGui::TextDisabled("This window is what Remote Play Together captures.");
ImGui::TextDisabled("F1: hide overlay (clean mirror) Esc: quit");
#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<int>(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).");
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<int>(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<double>(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<int>(kMaxPads); ++i)
{
total_rate += state_rate_[i];
}
if (total_rate > 0.0)
{
ImGui::TextColored(kGreen, "Game reading controller: %.0f polls/s", total_rate);
}
else
{
ImGui::TextColored(kGrey, "Game reading controller: idle");
}
if (debug_details &&
ImGui::BeginTable("slots", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
ImGui::TableSetupColumn("Slot");
ImGui::TableSetupColumn("GetState/s");
ImGui::TableSetupColumn("GetState total");
ImGui::TableSetupColumn("GetCaps total");
ImGui::TableHeadersRow();
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("%d", i);
ImGui::TableNextColumn();
if (state_rate_[i] > 0.0)
{
ImGui::TextColored(kGreen, "%.0f", state_rate_[i]);
}
else
{
ImGui::TextDisabled("0");
}
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(status.get_state[i]));
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(status.get_caps[i]));
}
ImGui::EndTable();
}
// Round-trip view: what we forwarded (the active backend's pad, tagged per slot in
// the Incoming section above) vs what the game actually read back through the hook.
// A mismatch isolates a tool->game forwarding problem from an input->tool one.
if (debug_details)
{
ImGui::SeparatorText("Round-trip (forwarded vs game read)");
if (ImGui::BeginTable("roundtrip", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
ImGui::TableSetupColumn("Slot");
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<int>(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();
ImGui::Text("0x%04X %d,%d", 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::EndTable();
}
ImGui::TextDisabled("Slot tags in 'Incoming' above show which backend (XInput / Steam) fed each slot.");
}
ImGui::End();
}
} // namespace coop