From b44c72349d88fef32dbf75c75f7ed455dc1ba60c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:05:12 -0700 Subject: [PATCH] Test JogCon steering, hotplug and live input --- docs/TCP_COMMANDS.md | 2 +- runtime/CMakeLists.txt | 3 ++ runtime/include/debug_server.h | 5 ++ runtime/include/psx_controller_type.h | 15 ++++++ runtime/src/debug_server.c | 30 +++++++++-- runtime/src/main.cpp | 67 ++++++++++++++++-------- runtime/tests/test_controller_type.cpp | 20 +++++++ runtime/tests/test_netplay_input_hist.c | 13 ++++- runtime/tests/test_psx_stick.cpp | 24 +++++++++ runtime/tests/test_sio_jogcon_protocol.c | 10 ++++ 10 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 runtime/tests/test_psx_stick.cpp diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index b20105ed..1a3fe74d 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -72,7 +72,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `cdrom_sector_history_clear` | ✓ | | — | Reset the CD-ROM sector history ring | | `watch` | ✓ | ✓ | `addr` | Set byte-level memory watchpoint (fires per-frame on change) | | `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint | -| `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames | +| `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry`, `pad_type` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Debug builds also accept `pad_type` (0 digital, 1 DualShock, 2 JogCon) to test an emulated device identity. Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames | | `clear_input` | ✓ | ✓ | — | Remove input and analog axis overrides | | `turbo` | ✓ | | `enabled` | Enable/disable TCP-controlled frontend turbo for fast-forward validation | | `turbo_state` | ✓ | | — | Query TCP-controlled turbo state | diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 3f6e7afd..a890ed6b 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -392,6 +392,9 @@ if(BUILD_TESTING) add_executable(controller_type_test tests/test_controller_type.cpp) target_include_directories(controller_type_test PRIVATE include) add_test(NAME controller_type_test COMMAND controller_type_test) + add_executable(psx_stick_test tests/test_psx_stick.cpp src/psx_stick.c) + target_include_directories(psx_stick_test PRIVATE include) + add_test(NAME psx_stick_test COMMAND psx_stick_test) add_executable(gte_register_access_test tests/test_gte_register_access.cpp diff --git a/runtime/include/debug_server.h b/runtime/include/debug_server.h index ba776a17..59ff00bc 100644 --- a/runtime/include/debug_server.h +++ b/runtime/include/debug_server.h @@ -204,6 +204,11 @@ int debug_server_get_input_override(void); * 0 when the injection is buttons-only. */ int debug_server_get_axis_override(unsigned char st[4]); +/* Optional debug-only emulated controller identity set by set_input/press: + * -1 follows normal device selection; otherwise 0=digital, 1=DualShock, + * 2=JogCon. */ +int debug_server_get_pad_type_override(void); + /* TCP-controlled turbo mode. When enabled the frontend skips presentation and * wall-clock pacing at vblank, matching the keyboard TAB turbo path. */ int debug_server_turbo_enabled(void); diff --git a/runtime/include/psx_controller_type.h b/runtime/include/psx_controller_type.h index f019d2eb..458a89f8 100644 --- a/runtime/include/psx_controller_type.h +++ b/runtime/include/psx_controller_type.h @@ -3,6 +3,7 @@ #include #include +#include #include /* SDL2/3 does not expose a consistently useful wheel class for every mapping. @@ -23,4 +24,18 @@ inline bool psx_controller_name_is_wheel(const char* name) { return false; } +/* Pure reopen policy so unplug and launcher-selected device changes can be + * regression-tested without a physical SDL controller. */ +inline bool psx_controller_handle_needs_close(bool controller_selected, + bool handle_open, + bool handle_attached, + const char* selected_guid, + const char* active_guid) { + if (!controller_selected) return true; + if (!handle_open) return false; + if (!handle_attached) return true; + return selected_guid && selected_guid[0] && active_guid && active_guid[0] && + std::strcmp(selected_guid, active_guid) != 0; +} + #endif diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index c789d8f5..a6973fc1 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -282,6 +282,9 @@ static int s_input_frames = 0; * pad sampler alongside the button word. */ static int s_axis_override = 0; static uint8_t s_axis_st[4] = { 0x80, 0x80, 0x80, 0x80 }; +/* Test-only controller identity override for set_input/press. -1 follows the + * selected host device/config; 0/1/2 force digital/DualShock/JogCon. */ +static int s_pad_type_override = -1; /* * Exact guest-VBlank input route. A client queues run-length encoded digital @@ -7763,9 +7766,14 @@ static void handle_unwatch(int id, const char *json) static void handle_set_input(int id, const char *json) { char val_str[32]; + const int pad_type = json_get_int(json, "pad_type", -1); if (!json_get_str(json, "buttons", val_str, sizeof(val_str))) { send_err(id, "missing buttons"); return; } + if (pad_type < -1 || pad_type > 2) { + send_err(id, "pad_type must be -1 (automatic), 0, 1, or 2"); return; + } + s_pad_type_override = pad_type; s_input_override = (int)hex_to_u32(val_str); s_input_frames = 0; /* Optional stick override: any of lx/ly/rx/ry (0..255) arms it; omitted @@ -7784,7 +7792,12 @@ static void handle_press(int id, const char *json) { int buttons = json_get_int(json, "buttons", -1); int frames = json_get_int(json, "frames", 2); + const int pad_type = json_get_int(json, "pad_type", -1); if (buttons < 0) { send_err(id, "missing buttons"); return; } + if (pad_type < -1 || pad_type > 2) { + send_err(id, "pad_type must be -1 (automatic), 0, 1, or 2"); return; + } + s_pad_type_override = pad_type; s_input_override = buttons; s_input_frames = frames; int ax[4] = { json_get_int(json, "lx", -1), json_get_int(json, "ly", -1), @@ -7813,16 +7826,18 @@ static void handle_pad_status(int id, const char *json) sio_get_pad_sticks(0, sticks0); sio_get_pad_sticks(1, sticks1); send_fmt("{\"id\":%d,\"ok\":true,\"pad\":\"0x%04X\"," - "\"slot0\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"sticks\":[%u,%u,%u,%u]}," - "\"slot1\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"sticks\":[%u,%u,%u,%u]}," - "\"override\":%d,\"override_frames\":%d," + "\"slot0\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]}," + "\"slot1\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]}," + "\"override\":%d,\"override_frames\":%d,\"override_pad_type\":%d," "\"override_axes\":[%u,%u,%u,%u],\"override_axes_valid\":%s}\n", id, pad0, pad0, sio_get_pad_connected(0) ? "true" : "false", sio_get_pad_analog(0) ? "true" : "false", + sio_get_pad_analog(0), sticks0[0], sticks0[1], sticks0[2], sticks0[3], pad1, sio_get_pad_connected(1) ? "true" : "false", sio_get_pad_analog(1) ? "true" : "false", + sio_get_pad_analog(1), sticks1[0], sticks1[1], sticks1[2], sticks1[3], - s_input_override, s_input_frames, + s_input_override, s_input_frames, s_pad_type_override, s_axis_st[0], s_axis_st[1], s_axis_st[2], s_axis_st[3], s_axis_override ? "true" : "false"); } @@ -7836,6 +7851,7 @@ static void handle_clear_input(int id, const char *json) s_input_override = -1; s_input_frames = 0; s_axis_override = 0; + s_pad_type_override = -1; s_axis_st[0] = s_axis_st[1] = s_axis_st[2] = s_axis_st[3] = 0x80; send_ok(id); } @@ -7882,6 +7898,7 @@ static void handle_input_route_start(int id, const char *json) s_input_override = -1; s_input_frames = 0; s_axis_override = 0; + s_pad_type_override = -1; s_input_route_index = 0; s_input_route_remaining = s_input_route[0].frames; s_input_route_active = 1; @@ -15684,6 +15701,11 @@ int debug_server_get_axis_override(unsigned char st[4]) return 1; } +int debug_server_get_pad_type_override(void) +{ + return s_pad_type_override; +} + int debug_server_turbo_enabled(void) { return s_turbo_enabled != 0; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 73006d35..8028a437 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -5038,21 +5038,21 @@ static void refresh_player_devices(void) { const int netplay = psx_netplay_active(); for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; - if (p.kind != 2 || - (p.handle && !SDL_GameControllerGetAttached(p.handle))) - close_player(p); /* kind change or unplug */ - else if (p.handle && p.guid[0]) { - /* The launcher may have assigned a different GUID while the old - * handle is still attached. Release it so open_player can honor - * the new selection and choose the matching emulated type. */ + char active_guid[40] = {0}; + const bool attached = p.handle && + SDL_GameControllerGetAttached(p.handle); + if (p.handle) { SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle); - SDL_JoystickGUID active_guid = joy ? SDL_JoystickGetGUID(joy) - : SDL_JoystickGUID{}; - char active[40] = {0}; - if (joy) SDL_JoystickGetGUIDString(active_guid, active, - (int)sizeof(active)); - if (joy && std::strcmp(active, p.guid) != 0) close_player(p); + if (joy) SDL_JoystickGetGUIDString(SDL_JoystickGetGUID(joy), + active_guid, + (int)sizeof(active_guid)); } + /* The launcher may change GUID while the old handle remains attached; + * pure policy covers that and unplug paths with mockable inputs. */ + if (psx_controller_handle_needs_close( + p.kind == 2, p.handle != nullptr, attached, + p.guid, active_guid)) + close_player(p); if (p.kind == 2) open_player(p, s); if (netplay) continue; const int mode = effective_player_mode_for_sio(p, s); @@ -5648,8 +5648,13 @@ static void apply_input_override_to_sio(int override_word) { const int effective_mode = controller_policy_resolve_override_mode( 0, 1, mode, w, st, stick_live, dpad_live); - const int eff_analog = + int eff_analog = effective_mode == (int)PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; +#ifndef PSX_NO_DEBUG_TOOLS + const int debug_type = debug_server_get_pad_type_override(); + if (debug_type == SIO_PAD_DUALSHOCK || debug_type == SIO_PAD_JOGCON) + eff_analog = 1; +#endif /* Injected input only (set_input / dev routing): fold the injected D-pad * word onto the left stick so stick-only menu/move paths respond to a * button-bit injection that has no physical stick behind it. @@ -5669,9 +5674,13 @@ static void apply_input_override_to_sio(int override_word) { } if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; } sio_set_pad_sticks(0, st[0], st[1], st[2], st[3]); - const int injected_type = p.steering_wheel ? SIO_PAD_JOGCON - : (eff_analog ? SIO_PAD_DUALSHOCK - : SIO_PAD_DIGITAL); + int injected_type = p.steering_wheel ? SIO_PAD_JOGCON + : (eff_analog ? SIO_PAD_DUALSHOCK + : SIO_PAD_DIGITAL); +#ifndef PSX_NO_DEBUG_TOOLS + if (debug_type >= SIO_PAD_DIGITAL && debug_type <= SIO_PAD_JOGCON) + injected_type = debug_type; +#endif sio_request_pad_type(0, injected_type); psx_selfcheck_note_pad(0, w, st[0], st[1], st[2], st[3], (uint8_t)injected_type); @@ -5716,8 +5725,13 @@ static int capture_pad_slot(int s, PsxNetPad* out) { s, player, mode, src, p, policy_buttons, st); const int eff_analog = effective_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; - const int frame_type = p.steering_wheel ? SIO_PAD_JOGCON - : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); + int frame_type = p.steering_wheel ? SIO_PAD_JOGCON + : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); +#ifndef PSX_NO_DEBUG_TOOLS + const int debug_type = debug_server_get_pad_type_override(); + if (debug_type >= SIO_PAD_DIGITAL && debug_type <= SIO_PAD_JOGCON) + frame_type = debug_type; +#endif if (savestate_input_guard_active()) { out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; @@ -5937,10 +5951,19 @@ static void capture_override_pad(int override_word, PsxNetPad* out) { const int effective_mode = controller_policy_resolve_override_mode( 0, 1, mode, w, st, stick_live, dpad_live); - const int eff_analog = + int eff_analog = effective_mode == (int)PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; - const int frame_type = p.steering_wheel ? SIO_PAD_JOGCON - : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); +#ifndef PSX_NO_DEBUG_TOOLS + const int debug_type = debug_server_get_pad_type_override(); + if (debug_type == SIO_PAD_DUALSHOCK || debug_type == SIO_PAD_JOGCON) + eff_analog = 1; +#endif + int frame_type = p.steering_wheel ? SIO_PAD_JOGCON + : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); +#ifndef PSX_NO_DEBUG_TOOLS + if (debug_type >= SIO_PAD_DIGITAL && debug_type <= SIO_PAD_JOGCON) + frame_type = debug_type; +#endif /* Injected input only; see the note on the sibling fold above. Not * hardware behaviour, retained solely so injection can steer stick-only * games. */ diff --git a/runtime/tests/test_controller_type.cpp b/runtime/tests/test_controller_type.cpp index 0a8821ae..39fc70e4 100644 --- a/runtime/tests/test_controller_type.cpp +++ b/runtime/tests/test_controller_type.cpp @@ -22,6 +22,26 @@ int main() { return 1; } } + struct HotplugCase { + bool selected, open, attached; + const char* selected_guid; + const char* active_guid; + bool close; + } hotplug[] = { + {true, false, false, "wheel-a", nullptr, false}, + {true, true, true, "wheel-a", "wheel-a", false}, + {true, true, false, "wheel-a", "wheel-a", true}, + {true, true, true, "wheel-b", "wheel-a", true}, + {false, true, true, "wheel-a", "wheel-a", true}, + {true, true, true, nullptr, "wheel-a", false}, + }; + for (const auto& test : hotplug) { + if (psx_controller_handle_needs_close(test.selected, test.open, + test.attached, test.selected_guid, test.active_guid) != test.close) { + std::fprintf(stderr, "hotplug policy failed\n"); + return 1; + } + } std::puts("controller type classification: passed"); return 0; } diff --git a/runtime/tests/test_netplay_input_hist.c b/runtime/tests/test_netplay_input_hist.c index 27acb333..5a5c9681 100644 --- a/runtime/tests/test_netplay_input_hist.c +++ b/runtime/tests/test_netplay_input_hist.c @@ -87,9 +87,20 @@ int main(void) netplay_ih_pad_to_frame(&pad, 9, 0, &dig); CHECK(dig.analog == 0u, "digital frame"); netplay_ih_frame_to_pad(&dig, &pad2); - CHECK(pad2.analog == 0u, "digital roundtrip"); + CHECK(pad2.analog == 0u, "digital roundtrip"); } + /* Restore the original DualShock sample for the ring-buffer assertions + * below; the preceding type/steering checks intentionally used later + * ticks and different pad modes. */ + memset(&pad, 0, sizeof(pad)); + pad.buttons = 0xFDFFu; + pad.lx = (uint8_t)(0x80 + 40); + pad.ly = pad.rx = pad.ry = 0x80; + pad.analog = SIO_PAD_DUALSHOCK; + pad.connected = 1; + netplay_ih_pad_to_frame(&pad, 10, 0, &f); + CHECK(netplay_ih_put(&h, 0, &f), "put local"); CHECK(netplay_ih_get(&h, 0, 10, &got), "get local"); CHECK(got.stick_x == 40, "get stick"); diff --git a/runtime/tests/test_psx_stick.cpp b/runtime/tests/test_psx_stick.cpp new file mode 100644 index 00000000..77ffe98e --- /dev/null +++ b/runtime/tests/test_psx_stick.cpp @@ -0,0 +1,24 @@ +#include "psx_stick.h" + +#include + +int main() { + uint8_t x = 0, y = 0; + psx_stick_to_dualshock(0, 0, 4096, 0, &x, &y); + if (x != 0x80 || y != 0x80) return 1; + + psx_stick_to_dualshock(4095, 0, 4096, 0, &x, &y); + if (x != 0x80 || y != 0x80) return 2; + + psx_stick_to_dualshock(-32768, 0, 4096, 0, &x, &y); + if (x != 0x00 || y != 0x80) return 3; + + psx_stick_to_dualshock(32767, 0, 4096, 0, &x, &y); + if (x != 0xFF || y != 0x80) return 4; + + psx_stick_to_dualshock(16384, 0, 4096, 0, &x, &y); + if (x <= 0x80 || x >= 0xFF || y != 0x80) return 5; + std::printf("stick mapping: center/left/right/partial pass (partial x=%u)\n", + (unsigned)x); + return 0; +} diff --git a/runtime/tests/test_sio_jogcon_protocol.c b/runtime/tests/test_sio_jogcon_protocol.c index d53ece90..f3a3a4ed 100644 --- a/runtime/tests/test_sio_jogcon_protocol.c +++ b/runtime/tests/test_sio_jogcon_protocol.c @@ -177,6 +177,16 @@ int main(void) { EXPECT("right.direction", 0x01, xchg(0, 0x00)); (void)xchg(0, 0x00); + /* Partial position stays proportional and reports movement toward center. */ + sio_set_pad_sticks(0, 0xC0, 0x80, 0x80, 0x80); + EXPECT("partial.prefix", 0xFF, xchg(0, 0x01)); + EXPECT("partial.id", 0xE3, xchg(0, 0x42)); + (void)xchg(0, 0x00); (void)xchg(0, 0x00); (void)xchg(0, 0x00); + EXPECT("partial.position", 0x40, xchg(0, 0x00)); + EXPECT("partial.sign", 0x00, xchg(0, 0x00)); + EXPECT("partial.direction", 0x02, xchg(0, 0x00)); + (void)xchg(0, 0x00); + EXPECT("rollback.restore", 1, sio_snapshot_read(snapshot, snapshot_len)); EXPECT("rollback.type", SIO_PAD_JOGCON, sio_get_pad_analog(0)); {