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>
This commit is contained in:
@@ -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
|
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.
|
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
|
- **Release the mouse cursor for cursor-clipping games.** Games that confine the
|
||||||
cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame
|
cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame
|
||||||
`SetCursorPos` re-centering) trap the operator's mouse permanently, because the
|
`SetCursorPos` re-centering) trap the operator's mouse permanently, because the
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace coop
|
|||||||
|
|
||||||
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
||||||
// refuses to attach to a host with a mismatched version.
|
// 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.
|
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
||||||
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
||||||
@@ -133,6 +133,10 @@ struct HookStatus
|
|||||||
// other diagnostics -- benign cross-process races are fine.
|
// other diagnostics -- benign cross-process races are fine.
|
||||||
std::uint16_t rumble_left[kMaxPads];
|
std::uint16_t rumble_left[kMaxPads];
|
||||||
std::uint16_t rumble_right[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
|
// Host -> hook control channel. The host requests which hook subsystems should be
|
||||||
|
|||||||
@@ -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 -------------------------------------
|
// --- Audio render-hook diagnostics -------------------------------------
|
||||||
|
|
||||||
// Total distinct render streams the audio hook has observed.
|
// Total distinct render streams the audio hook has observed.
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ DWORD query_state(DWORD user_index, XINPUT_STATE* state, bool keep_guide)
|
|||||||
result.Gamepad.wButtons &= ~kGuideButton;
|
result.Gamepad.wButtons &= ~kGuideButton;
|
||||||
}
|
}
|
||||||
*state = result;
|
*state = result;
|
||||||
|
if (g_ipc != nullptr)
|
||||||
|
{
|
||||||
|
g_ipc->note_read_state(user_index, pad); // round-trip: what the game just read
|
||||||
|
}
|
||||||
return ERROR_SUCCESS;
|
return ERROR_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,37 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
|
|||||||
ImGui::EndTable();
|
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();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ HookStatusView IpcServer::hook_status() const
|
|||||||
{
|
{
|
||||||
view.rumble_left[i] = s.rumble_left[i];
|
view.rumble_left[i] = s.rumble_left[i];
|
||||||
view.rumble_right[i] = s.rumble_right[i];
|
view.rumble_right[i] = s.rumble_right[i];
|
||||||
|
view.read_state[i] = s.read_state[i];
|
||||||
}
|
}
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ struct HookStatusView
|
|||||||
// Per-slot rumble the game requested (host forwards it to the guest's pad).
|
// Per-slot rumble the game requested (host forwards it to the guest's pad).
|
||||||
std::uint16_t rumble_left[kMaxPads] = {};
|
std::uint16_t rumble_left[kMaxPads] = {};
|
||||||
std::uint16_t rumble_right[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.
|
// Plain snapshot of the Present-hook video channel for the Video mirror panel.
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ int main()
|
|||||||
check(block->status.rumble_left[0] == 0x8000 && block->status.rumble_right[0] == 0x4000,
|
check(block->status.rumble_left[0] == 0x8000 && block->status.rumble_right[0] == 0x4000,
|
||||||
"status records rumble from XInputSetState");
|
"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();
|
hook::remove_xinput_hooks();
|
||||||
|
|
||||||
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);
|
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);
|
||||||
|
|||||||
Reference in New Issue
Block a user