From efe685cf1edb20b62f3d93bcb22bb649c14afc60 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:32:24 -0700 Subject: [PATCH] JogCon force feedback on host wheels (SDL haptics, opt-in) The emulated JogCon kept its motor command as device state only. With game.toml [controller] jogcon_force_feedback = true, a presented JogCon's motor byte (command nibble + strength) now drives a host wheel's SDL haptics: commands 1 / 2 a constant force signed by direction and scaled by strength, any other non-stop command a centring spring. Only pads whose haptic device supports constant or spring effects are driven, so gamepads are untouched. jogcon_force_feedback_invert flips the turn direction (not checked on JogCon hardware). SIO keeps the strength nibble beside the command (sio_get_pad_jogcon_motor). PSX_RUMBLE_TRACE logs the commands, headless too. Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 6 ++ recompiler/src/config_loader.h | 6 ++ runtime/CMakeLists.txt | 4 + runtime/include/psx_jogcon_ffb.h | 43 ++++++++ runtime/include/sio.h | 3 + runtime/src/main.cpp | 120 +++++++++++++++++++++++ runtime/src/sio.c | 20 ++++ runtime/tests/test_jogcon_ffb.c | 36 +++++++ runtime/tests/test_sio_jogcon_protocol.c | 8 ++ 9 files changed, 246 insertions(+) create mode 100644 runtime/include/psx_jogcon_ffb.h create mode 100644 runtime/tests/test_jogcon_ffb.c diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 3024b8ea..b1978da8 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1080,6 +1080,12 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.direct_shortcut_button = toml::find(ct, "direct_shortcut_button"); } + if (ct.contains("jogcon_force_feedback")) + rt.jogcon_force_feedback = + toml::find(ct, "jogcon_force_feedback"); + if (ct.contains("jogcon_force_feedback_invert")) + rt.jogcon_force_feedback_invert = + toml::find(ct, "jogcon_force_feedback_invert"); if (ct.contains("anti_deadzone")) { const auto n = toml::find(ct, "anti_deadzone"); if (n < 0 || n > 32767) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1cefbd03..e829f78b 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -806,6 +806,12 @@ struct RuntimeConfig { std::string direct_shortcut; std::string direct_shortcut_button; + // jogcon_force_feedback: drive a host wheel's SDL haptics (constant force + // / centring spring) from a presented JogCon's motor commands. Opt-in; + // gamepads are never driven. _invert flips the turn direction. + bool jogcon_force_feedback = false; + bool jogcon_force_feedback_invert = false; + // multitap_analog: DualShock-on-tap hack (default true). When true, // multitap bulk seats may report 0x73 + stick bytes; when false (faithful), // tap seats stay plain digital. Overridable by settings.toml / match_caps. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5e84519f..67c8e98a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -709,6 +709,10 @@ if(BUILD_TESTING) add_executable(host_rumble_test tests/test_host_rumble.c) target_include_directories(host_rumble_test PRIVATE include) add_test(NAME host_rumble_test COMMAND host_rumble_test) + # JogCon motor byte -> host wheel haptics ([controller] jogcon_force_feedback). + add_executable(jogcon_ffb_test tests/test_jogcon_ffb.c) + target_include_directories(jogcon_ffb_test PRIVATE include) + add_test(NAME jogcon_ffb_test COMMAND jogcon_ffb_test) add_executable(controller_type_test tests/test_controller_type.cpp) target_include_directories(controller_type_test PRIVATE include) diff --git a/runtime/include/psx_jogcon_ffb.h b/runtime/include/psx_jogcon_ffb.h new file mode 100644 index 00000000..da4c6943 --- /dev/null +++ b/runtime/include/psx_jogcon_ffb.h @@ -0,0 +1,43 @@ +/* psx_jogcon_ffb.h - JogCon force feedback onto a host wheel's haptics. + * + * The JogCon's motor takes one byte per 0x42 poll at the motor slot the game + * mapped with 0x4D: high nibble = command, low nibble = strength (0..15). + * Command 0 stops the motor; 1 and 2 turn the wheel one way or the other; + * any other command holds it against the player's turn. A host wheel with + * SDL haptics gets the same thing: a constant force for 1 / 2 (signed by + * direction, scaled by strength) and a centring spring for a hold. Gamepads + * are not driven (no wheel to turn). Opt-in per title with game.toml + * [controller] jogcon_force_feedback = true. Which way 1 and 2 turn has not + * been checked on JogCon hardware; jogcon_force_feedback_invert flips it. + * Pure (no SDL), so tests link it directly. */ +#ifndef PSX_JOGCON_FFB_H +#define PSX_JOGCON_FFB_H + +#include + +#define PSX_JOGCON_MOTOR_STOP 0u +#define PSX_JOGCON_MOTOR_CW 1u +#define PSX_JOGCON_MOTOR_CCW 2u + +typedef struct PsxJogconFfb { + int16_t constant; /* SDL constant-force level, -32767..32767 */ + uint16_t spring; /* SDL spring coefficient, 0..32767 */ +} PsxJogconFfb; + +static inline PsxJogconFfb psx_jogcon_ffb_map(uint8_t command, uint8_t strength, + int invert) { + PsxJogconFfb f = {0, 0}; + const int level = (int)(strength & 0x0Fu) * 32767 / 15; + command &= 0x0Fu; + if (command == PSX_JOGCON_MOTOR_STOP || !level) return f; + if (command == PSX_JOGCON_MOTOR_CW || command == PSX_JOGCON_MOTOR_CCW) { + int v = command == PSX_JOGCON_MOTOR_CW ? level : -level; + if (invert) v = -v; + f.constant = (int16_t)v; + } else { + f.spring = (uint16_t)level; + } + return f; +} + +#endif /* PSX_JOGCON_FFB_H */ diff --git a/runtime/include/sio.h b/runtime/include/sio.h index dab3ee58..b2defec8 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -202,6 +202,9 @@ void sio_set_pad_config_capable(int slot, int capable); * motor byte; `large` is the variable-strength low-frequency motor byte. * The frontend translates these values to its host controller API. */ void sio_get_pad_rumble(int slot, uint8_t *small, uint8_t *large); +/* JogCon motor: last command nibble and strength (0..15) the guest sent; 0, 0 + * unless the slot is a connected JogCon. Host force feedback reads it. */ +void sio_get_pad_jogcon_motor(int slot, uint8_t *command, uint8_t *strength); /* Return current pad button state (for debug server). _slot targets a logical * pad 0 .. PSX_MAX_PLAYERS-1. */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index cf33df28..59f8976f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -68,6 +68,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "latency_ring.h" #include "sio.h" #include "psx_host_rumble.h" +#include "psx_jogcon_ffb.h" #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif @@ -432,7 +433,17 @@ struct PlayerInput { bool rumble_known = false; bool rumble_warned = false; bool steering_wheel = false; + /* JogCon force feedback on this pad's haptics ([controller] + * jogcon_force_feedback; psx_jogcon_ffb.h). -1 = no such effect. */ + SDL_Haptic* haptic = nullptr; + int ffb_constant = -1; + int ffb_spring = -1; + bool ffb_probed = false; + PsxJogconFfb ffb_last = {0, 0}; }; +/* game.toml [controller] jogcon_force_feedback(_invert): opt-in. */ +static bool g_jogcon_ffb = false; +static bool g_jogcon_ffb_invert = false; static PlayerInput g_players[PSX_MAX_PLAYERS]; /* Offline SIO sample loop bound (from game.toml players; clamped). */ static int g_offline_pad_count = 2; @@ -5184,7 +5195,18 @@ static void load_input_config(const char* argv0) { psx_keybinds_init(argv0); } +static void close_player_haptic(PlayerInput& p) { + if (p.haptic) { + SDL_HapticClose(p.haptic); /* stops and frees its effects */ + p.haptic = nullptr; + } + p.ffb_constant = p.ffb_spring = -1; + p.ffb_probed = false; + p.ffb_last = PsxJogconFfb{0, 0}; +} + static void close_player(PlayerInput& p) { + close_player_haptic(p); if (p.handle) { if (p.rumble_small || p.rumble_large) (void)SDL_GameControllerRumble(p.handle, 0, 0, 0); @@ -5285,6 +5307,101 @@ static void open_player(PlayerInput& p, int self_slot) { * the small motor is fixed-strength/high-frequency. Active effects are renewed * every VBlank with a short lifetime so a crash or unplug cannot leave a host * controller vibrating indefinitely. */ +#if defined(PSX_SDL3) +#define PSX_SDL_OK(call) ((call) ? 0 : -1) +#else +#define PSX_SDL_OK(call) (call) +#endif + +/* Open the pad's haptic device and make the two JogCon effects, once per + * pad. Pads without constant / spring support (gamepads) get none. */ +static void probe_jogcon_haptic(PlayerInput& p) { + p.ffb_probed = true; + if (!p.handle) return; + if (!SDL_WasInit(SDL_INIT_HAPTIC) && + PSX_SDL_OK(SDL_InitSubSystem(SDL_INIT_HAPTIC)) != 0) + return; + SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle); + p.haptic = joy ? SDL_HapticOpenFromJoystick(joy) : nullptr; + if (!p.haptic) return; + const unsigned features = (unsigned)SDL_HapticQuery(p.haptic); + SDL_HapticEffect e; + if (features & SDL_HAPTIC_CONSTANT) { + std::memset(&e, 0, sizeof e); + e.type = SDL_HAPTIC_CONSTANT; + e.constant.direction.type = SDL_HAPTIC_CARTESIAN; + e.constant.direction.dir[0] = 1; + e.constant.length = SDL_HAPTIC_INFINITY; + p.ffb_constant = SDL_HapticNewEffect(p.haptic, &e); + } + if (features & SDL_HAPTIC_SPRING) { + std::memset(&e, 0, sizeof e); + e.type = SDL_HAPTIC_SPRING; + e.condition.direction.type = SDL_HAPTIC_CARTESIAN; + e.condition.length = SDL_HAPTIC_INFINITY; + e.condition.right_sat[0] = e.condition.left_sat[0] = 0xFFFF; + p.ffb_spring = SDL_HapticNewEffect(p.haptic, &e); + } + if (p.ffb_constant < 0 && p.ffb_spring < 0) { + SDL_HapticClose(p.haptic); + p.haptic = nullptr; + } else { + std::fprintf(stdout, + "psxrecomp runtime: JogCon force feedback on this controller " + "(constant %s, spring %s)\n", p.ffb_constant >= 0 ? "yes" : "no", + p.ffb_spring >= 0 ? "yes" : "no"); + } +} + +static void apply_jogcon_effect(PlayerInput& p, int effect, bool on, + SDL_HapticEffect* e) { + if (!p.haptic || effect < 0) return; + if (on) { + (void)SDL_HapticUpdateEffect(p.haptic, effect, e); + (void)SDL_HapticRunEffect(p.haptic, effect, 1); + } else { + (void)SDL_HapticStopEffect(p.haptic, effect); + } +} + +/* JogCon force feedback ([controller] jogcon_force_feedback): the guest's + * motor command and strength become a constant force or a centring spring on + * a host wheel with SDL haptics. Gamepads are left alone. */ +static void update_jogcon_ffb(int s, PlayerInput& p, bool trace) { + PsxJogconFfb f = {0, 0}; + uint8_t command = 0, strength = 0; + if (g_jogcon_ffb && sio_get_pad_analog(s) == SIO_PAD_JOGCON) { + sio_get_pad_jogcon_motor(s, &command, &strength); + f = psx_jogcon_ffb_map(command, strength, g_jogcon_ffb_invert ? 1 : 0); + if (!p.ffb_probed) probe_jogcon_haptic(p); + } + if (f.constant == p.ffb_last.constant && f.spring == p.ffb_last.spring) + return; + if (p.haptic) { + SDL_HapticEffect e; + std::memset(&e, 0, sizeof e); + e.type = SDL_HAPTIC_CONSTANT; + e.constant.direction.type = SDL_HAPTIC_CARTESIAN; + e.constant.direction.dir[0] = 1; + e.constant.length = SDL_HAPTIC_INFINITY; + e.constant.level = f.constant; + apply_jogcon_effect(p, p.ffb_constant, f.constant != 0, &e); + std::memset(&e, 0, sizeof e); + e.type = SDL_HAPTIC_SPRING; + e.condition.direction.type = SDL_HAPTIC_CARTESIAN; + e.condition.length = SDL_HAPTIC_INFINITY; + e.condition.right_sat[0] = e.condition.left_sat[0] = 0xFFFF; + e.condition.right_coeff[0] = e.condition.left_coeff[0] = (Sint16)f.spring; + apply_jogcon_effect(p, p.ffb_spring, f.spring != 0, &e); + } + if (trace) + std::fprintf(stdout, + "psxrecomp ffb: slot=%d jogcon cmd=%u strength=%u constant=%d " + "spring=%u host=%s\n", s + 1, (unsigned)command, (unsigned)strength, + (int)f.constant, (unsigned)f.spring, p.haptic ? "wheel" : "none"); + p.ffb_last = f; +} + static void update_controller_rumble(void) { static const bool trace = [] { const char* e = std::getenv("PSX_RUMBLE_TRACE"); @@ -5293,6 +5410,7 @@ static void update_controller_rumble(void) { ++g_rumble_vblank; for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; + update_jogcon_ffb(s, p, trace); uint8_t small = 0, large = 0; sio_get_pad_rumble(s, &small, &large); psx_host_rumble_merge(&g_mod_rumble[s], g_rumble_vblank, &small, &large); @@ -16617,6 +16735,8 @@ int main(int argc, char** argv) { g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0; /* [controller] game-declared input defaults (settings.toml/launcher * still override below). */ + g_jogcon_ffb = gc.runtime.jogcon_force_feedback; + g_jogcon_ffb_invert = gc.runtime.jogcon_force_feedback_invert; if (!gc.runtime.direct_shortcut.empty()) apply_title_direct_shortcut(gc.runtime.direct_shortcut, gc.runtime.direct_shortcut_button); diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 67f6e3b9..78519b72 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -140,6 +140,9 @@ static PSX_BSS uint8_t pad_in_config[PSX_MAX_PLAYERS]; * the frontend's vibration/force-feedback capabilities. */ static PSX_BSS int8_t pad_jogcon_last_steering[PSX_MAX_PLAYERS]; static PSX_BSS uint8_t pad_jogcon_motor_command[PSX_MAX_PLAYERS]; +/* The same motor byte's strength nibble, for host force feedback only (not + * echoed to the guest and not savestated: every poll rewrites it). */ +static PSX_BSS uint8_t pad_jogcon_motor_strength[PSX_MAX_PLAYERS]; /* Whether the pad on a logical slot is config-capable (DualShock/JogCon, 1) or a * plain digital controller (0). A real SCPH-1080 digital pad (poll id 0x41) @@ -854,6 +857,7 @@ void sio_init(void) { memset(pad_rumble_large, 0, sizeof(pad_rumble_large)); memset(pad_jogcon_last_steering, 0, sizeof(pad_jogcon_last_steering)); memset(pad_jogcon_motor_command, 0, sizeof(pad_jogcon_motor_command)); + memset(pad_jogcon_motor_strength, 0, sizeof(pad_jogcon_motor_strength)); memset(pad_negcon_analog, 0, sizeof(pad_negcon_analog)); for (int i = 0; i < PSX_MAX_PLAYERS; i++) { pad_analog_device[i] = SIO_PAD_DUALSHOCK; @@ -1061,6 +1065,7 @@ void sio_set_pad_connected(int slot, int connected) { pad_rumble_large[slot] = 0; pad_jogcon_last_steering[slot] = 0; pad_jogcon_motor_command[slot] = 0; + pad_jogcon_motor_strength[slot] = 0; } } @@ -1077,6 +1082,17 @@ void sio_set_pad_config_capable(int slot, int capable) { } } +void sio_get_pad_jogcon_motor(int slot, uint8_t *command, uint8_t *strength) { + uint8_t c = 0, s = 0; + if (slot >= 0 && slot < PSX_MAX_PLAYERS && (pad_connected & (1u << slot)) && + pad_analog[slot] == SIO_PAD_JOGCON) { + c = pad_jogcon_motor_command[slot]; + s = pad_jogcon_motor_strength[slot]; + } + if (command) *command = c; + if (strength) *strength = s; +} + void sio_get_pad_rumble(int slot, uint8_t *small, uint8_t *large) { uint8_t s = 0, l = 0; if (slot >= 0 && slot < PSX_MAX_PLAYERS && (pad_connected & (1u << slot))) { @@ -1111,6 +1127,7 @@ static void pad_commit_type(int slot, uint8_t type) { if (from != type) { pad_jogcon_last_steering[slot] = 0; pad_jogcon_motor_command[slot] = 0; + pad_jogcon_motor_strength[slot] = 0; } if (pad_type_is_device_swap(from, type)) { pad_in_config[slot] = 0; @@ -1615,6 +1632,7 @@ static void pad_process_byte(uint8_t tx_byte) { const uint8_t motor = pad_rumble_map[rs][map_index]; if (pad_analog[rs] == SIO_PAD_JOGCON && motor == 0x00) { pad_jogcon_motor_command[rs] = (uint8_t)(tx_byte >> 4); + pad_jogcon_motor_strength[rs] = (uint8_t)(tx_byte & 0x0Fu); } else if (motor == 0x00) pad_rumble_small[rs] = tx_byte; else if (motor == 0x01) @@ -3139,6 +3157,7 @@ static int sio_snap_parse(PstR *r) { memset(pad_rumble_large, 0, sizeof(pad_rumble_large)); memset(pad_jogcon_last_steering, 0, sizeof(pad_jogcon_last_steering)); memset(pad_jogcon_motor_command, 0, sizeof(pad_jogcon_motor_command)); + memset(pad_jogcon_motor_strength, 0, sizeof(pad_jogcon_motor_strength)); for (int s = 0; s < PSX_MAX_PLAYERS; s++) { pad_rumble_map[s][0] = 0x00; pad_rumble_map[s][1] = 0x01; @@ -3155,6 +3174,7 @@ static int sio_snap_parse(PstR *r) { if (r->p == r->end) { memset(pad_jogcon_last_steering, 0, sizeof(pad_jogcon_last_steering)); memset(pad_jogcon_motor_command, 0, sizeof(pad_jogcon_motor_command)); + memset(pad_jogcon_motor_strength, 0, sizeof(pad_jogcon_motor_strength)); sio_pad_mode_legacy_defaults(); return 1; } diff --git a/runtime/tests/test_jogcon_ffb.c b/runtime/tests/test_jogcon_ffb.c new file mode 100644 index 00000000..7842ae27 --- /dev/null +++ b/runtime/tests/test_jogcon_ffb.c @@ -0,0 +1,36 @@ +/* psx_jogcon_ffb.h: JogCon motor byte -> wheel constant force / spring. */ +#include +#include "psx_jogcon_ffb.h" + +static int s_fail; +#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL %s\n", m); ++s_fail; } } while (0) + +int main(void) { + PsxJogconFfb f = psx_jogcon_ffb_map(0, 15, 0); + CHECK(f.constant == 0 && f.spring == 0, "stop: no force"); + f = psx_jogcon_ffb_map(1, 0, 0); + CHECK(f.constant == 0 && f.spring == 0, "strength 0: no force"); + f = psx_jogcon_ffb_map(1, 15, 0); + CHECK(f.constant == 32767 && f.spring == 0, "command 1 full: +constant"); + f = psx_jogcon_ffb_map(2, 15, 0); + CHECK(f.constant == -32767, "command 2 full: -constant"); + f = psx_jogcon_ffb_map(2, 15, 1); + CHECK(f.constant == 32767, "invert flips the direction"); + f = psx_jogcon_ffb_map(1, 5, 0); + CHECK(f.constant == 10922, "strength scales linearly (5/15)"); + { + int prev = 0; + for (uint8_t st = 1; st <= 15; ++st) { + f = psx_jogcon_ffb_map(1, st, 0); + CHECK(f.constant > prev, "monotonic in strength"); + prev = f.constant; + } + } + f = psx_jogcon_ffb_map(3, 8, 0); + CHECK(f.constant == 0 && f.spring == 8 * 32767 / 15, "hold: centring spring"); + f = psx_jogcon_ffb_map(0x21, 0x0F, 0); + CHECK(f.constant == 32767, "only the low nibbles count"); + if (s_fail) return 1; + puts("JogCon force feedback tests passed"); + return 0; +} diff --git a/runtime/tests/test_sio_jogcon_protocol.c b/runtime/tests/test_sio_jogcon_protocol.c index 1ad71da0..e49a90f3 100644 --- a/runtime/tests/test_sio_jogcon_protocol.c +++ b/runtime/tests/test_sio_jogcon_protocol.c @@ -286,6 +286,14 @@ int main(void) { (void)xchg(0, 0x00); (void)xchg(0, 0x21); for (int i = 0; i < 5; i++) (void)xchg(0, 0x00); + { /* host force feedback reads command and strength */ + uint8_t cmd = 0xEE, strength = 0xEE; + sio_get_pad_jogcon_motor(0, &cmd, &strength); + EXPECT("motor.host.command", 0x02, cmd); + EXPECT("motor.host.strength", 0x01, strength); + sio_get_pad_jogcon_motor(1, &cmd, &strength); + EXPECT("motor.host.other_slot", 0x00, cmd | strength); + } EXPECT("motor.status.prefix", 0xFF, xchg(0, 0x01)); EXPECT("motor.status.id", 0xF3, xchg(0, 0x42)); for (int i = 0; i < 5; i++) (void)xchg(0, 0x00);