From 327ba1f394eddafe01e237c395a7d9993be7797d Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 05:15:07 +0200 Subject: [PATCH] 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 --- README.md | 9 --------- common/include/coop/protocol.hpp | 6 +++++- hook/src/ipc_client.hpp | 9 +++++++++ hook/src/xinput_hook.cpp | 4 ++++ host/src/controllers_panel.cpp | 31 +++++++++++++++++++++++++++++++ host/src/ipc/ipc_server.cpp | 1 + host/src/ipc/ipc_server.hpp | 3 +++ tests/hook_selftest.cpp | 4 ++++ 8 files changed, 57 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fd28b2f..713b1a2 100644 --- a/README.md +++ b/README.md @@ -76,15 +76,6 @@ is removed from this list once done — so the top item is always next. The self-verifiable tooling / UI / input items come first; the game-pipeline items that need a real game (and Remote Play) to fully validate come last. -- **Per-backend input debug visualization.** To separate "wrong input *into* the - tool" from "wrong input *out to* the game", show three distinct views in the - Controllers panel (under Debug details): (a) **received via XInput** (raw - `XInputSource` state), (b) **received via Steam Input** (raw `SteamInputSource` - action values) — so it's obvious which backend delivered what — and (c) - **forwarded to the game** (the `PadInfo` we write to shared memory, alongside what - the game actually read back via the hook's per-slot channel). The hook already - reports per-slot poll counts; extend it to echo the last state the game read so (c) - is a true round-trip. - **Release the mouse cursor for cursor-clipping games.** Games that confine the cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame `SetCursorPos` re-centering) trap the operator's mouse permanently, because the diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 9351173..5708a9a 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -12,7 +12,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // refuses to attach to a host with a mismatched version. -inline constexpr std::uint32_t kProtocolVersion = 9; +inline constexpr std::uint32_t kProtocolVersion = 10; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -133,6 +133,10 @@ struct HookStatus // other diagnostics -- benign cross-process races are fine. std::uint16_t rumble_left[kMaxPads]; std::uint16_t rumble_right[kMaxPads]; + + // The last pad state the hook actually returned to the game per slot, so the host + // can show a true input round-trip (forwarded vs what the game read). + CoopPadState read_state[kMaxPads]; }; // Host -> hook control channel. The host requests which hook subsystems should be diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index efaa05a..cb3ca35 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -151,6 +151,15 @@ public: } } + // Record the state the hook just returned to the game for a slot (round-trip view). + void note_read_state(std::uint32_t slot, const CoopPadState& state) + { + if (block_ != nullptr && slot < kMaxPads) + { + block_->status.read_state[slot] = state; + } + } + // --- Audio render-hook diagnostics ------------------------------------- // Total distinct render streams the audio hook has observed. diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp index 36a6090..fe9c0b7 100644 --- a/hook/src/xinput_hook.cpp +++ b/hook/src/xinput_hook.cpp @@ -90,6 +90,10 @@ DWORD query_state(DWORD user_index, XINPUT_STATE* state, bool keep_guide) result.Gamepad.wButtons &= ~kGuideButton; } *state = result; + if (g_ipc != nullptr) + { + g_ipc->note_read_state(user_index, pad); // round-trip: what the game just read + } return ERROR_SUCCESS; } diff --git a/host/src/controllers_panel.cpp b/host/src/controllers_panel.cpp index 26e1fc3..ce0e07f 100644 --- a/host/src/controllers_panel.cpp +++ b/host/src/controllers_panel.cpp @@ -182,6 +182,37 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat 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(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(); } diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index dc2e7a6..44eaf4a 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -89,6 +89,7 @@ HookStatusView IpcServer::hook_status() const { view.rumble_left[i] = s.rumble_left[i]; view.rumble_right[i] = s.rumble_right[i]; + view.read_state[i] = s.read_state[i]; } return view; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index e659d65..9938813 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -40,6 +40,9 @@ struct HookStatusView // Per-slot rumble the game requested (host forwards it to the guest's pad). std::uint16_t rumble_left[kMaxPads] = {}; std::uint16_t rumble_right[kMaxPads] = {}; + + // The last pad state the hook returned to the game per slot (round-trip view). + CoopPadState read_state[kMaxPads] = {}; }; // Plain snapshot of the Present-hook video channel for the Video mirror panel. diff --git a/tests/hook_selftest.cpp b/tests/hook_selftest.cpp index 86f7367..f70a123 100644 --- a/tests/hook_selftest.cpp +++ b/tests/hook_selftest.cpp @@ -175,6 +175,10 @@ int main() check(block->status.rumble_left[0] == 0x8000 && block->status.rumble_right[0] == 0x4000, "status records rumble from XInputSetState"); + // Round-trip: the hook echoes the state it returned to the game (for the + // Controllers panel's forwarded-vs-read view). + check(block->status.read_state[0].buttons == (kButtonA | kButtonB), "status records game-read state"); + hook::remove_xinput_hooks(); std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);