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 1/4] 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); From 05931590a3bcab012f1166bb64025ee60381b8a1 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:44:05 -0700 Subject: [PATCH 2/4] JogCon FFB: experimental opt-in, neutral on pause / disconnect / end Review (mstan): the title key is now experimental_jogcon_force_feedback( _invert) and the runtime logs it as EXPERIMENTAL. Force feedback goes through a pure driver (psx_jogcon_ffb.h) and an SDL haptic backend (psx_jogcon_ffb_sdl.cpp): every force has a 120 ms lifetime renewed each VBlank, so a pause, stall or crash goes slack by itself, and the Rewind and save-state pause loops, disconnect (close_player) and session end (close_controller) stop it explicitly. Tests: jogcon_ffb_test drives the driver against a recording backend (lifetime, renew, stop once, neutral, idempotence, disable mid-force); jogcon_ffb_sdl_test runs the SDL backend against an SDL3 virtual wheel. SDL has no virtual haptic device, so it checks that such a device is not driven, never falls back to rumble and closes cleanly. Also fixes the SDL3 haptic subsystem init check (psx_sdl.h returns 0 on success). Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 12 ++ recompiler/src/config_loader.cpp | 12 +- recompiler/src/config_loader.h | 11 +- runtime/CMakeLists.txt | 10 ++ runtime/include/psx_jogcon_ffb.h | 58 +++++++++- runtime/include/psx_jogcon_ffb_sdl.h | 26 +++++ runtime/runtime.cmake | 1 + runtime/src/main.cpp | 156 +++++++++----------------- runtime/src/psx_jogcon_ffb_sdl.cpp | 98 ++++++++++++++++ runtime/tests/test_jogcon_ffb.c | 39 +++++++ runtime/tests/test_jogcon_ffb_sdl.cpp | 64 +++++++++++ 11 files changed, 370 insertions(+), 117 deletions(-) create mode 100644 runtime/include/psx_jogcon_ffb_sdl.h create mode 100644 runtime/src/psx_jogcon_ffb_sdl.cpp create mode 100644 runtime/tests/test_jogcon_ffb_sdl.cpp diff --git a/docs/config_schema.md b/docs/config_schema.md index 6950f926..d83251af 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -867,6 +867,18 @@ pad with the louder of that and the guest's SIO motors; a value lapses 8 VBlanks after the last call and all are cleared at every mod/session reset. `PSX_RUMBLE_TRACE=1` logs every change, headless included. +**EXPERIMENTAL:** `[controller] experimental_jogcon_force_feedback = true` +drives a host force-feedback wheel's SDL haptics from a presented JogCon's +motor commands: a constant force for turn commands 1 and 2, and a centring +spring for hold. It is off by default. Gamepads and devices without constant +or spring haptics are never driven. Every force has a 120 ms lifetime and is +renewed each VBlank, so a pause, stall or crash leaves the wheel slack. The +Rewind and save-state pause menus, disconnects and session end also stop it +explicitly. The turn direction has not been checked on JogCon hardware or a +physical wheel; `experimental_jogcon_force_feedback_invert = true` flips it. +SDL has no virtual haptic device, so CI covers the driver with a recording +backend and the SDL backend only against an SDL3 virtual wheel (no haptics). + Bezel artwork is intentionally not a `[video]` key. It is exposed as the disabled-by-default `psx.presentation.bezel` mod package, which draws a user-selected image resource behind the game image in OpenGL letterbox or diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index b1978da8..9d72ed21 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1080,12 +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("experimental_jogcon_force_feedback")) + rt.experimental_jogcon_force_feedback = + toml::find(ct, "experimental_jogcon_force_feedback"); + if (ct.contains("experimental_jogcon_force_feedback_invert")) + rt.experimental_jogcon_force_feedback_invert = + toml::find(ct, "experimental_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 e829f78b..27d803ae 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -806,11 +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; + // experimental_jogcon_force_feedback: EXPERIMENTAL. Drive a host wheel's + // SDL haptics (constant force / centring spring) from a presented + // JogCon's motor commands. Opt-in; gamepads are never driven; direction + // unverified on hardware (_invert flips it). + bool experimental_jogcon_force_feedback = false; + bool experimental_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), diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 67c8e98a..08351109 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -713,6 +713,16 @@ if(BUILD_TESTING) 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) + # The SDL haptic backend against an SDL3 virtual wheel (no virtual haptic + # device exists in SDL; see the test). Skips (77) on SDL2. + add_executable(jogcon_ffb_sdl_test tests/test_jogcon_ffb_sdl.cpp + src/psx_jogcon_ffb_sdl.cpp) + target_include_directories(jogcon_ffb_sdl_test PRIVATE include) + target_link_libraries(jogcon_ffb_sdl_test PRIVATE ${PSX_SDL_LIBRARIES}) + target_compile_definitions(jogcon_ffb_sdl_test PRIVATE + $<$:PSX_SDL3=1>) + add_test(NAME jogcon_ffb_sdl_test COMMAND jogcon_ffb_sdl_test) + set_tests_properties(jogcon_ffb_sdl_test PROPERTIES SKIP_RETURN_CODE 77) 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 index da4c6943..2284b59d 100644 --- a/runtime/include/psx_jogcon_ffb.h +++ b/runtime/include/psx_jogcon_ffb.h @@ -6,10 +6,16 @@ * 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. */ + * are not driven (no wheel to turn). EXPERIMENTAL, opt-in per title with + * game.toml [controller] experimental_jogcon_force_feedback = true. Which way + * 1 and 2 turn has not been checked on JogCon hardware or a physical wheel; + * experimental_jogcon_force_feedback_invert flips it. + * + * Neutral by construction: every force is played with a finite lifetime + * (PSX_JOGCON_FFB_LIFETIME_MS) and renewed each VBlank, so a pause, stall or + * crash lets the wheel go slack on its own; pause loops, disconnects and + * session end also stop it explicitly (psx_jogcon_ffb_neutral). + * Pure (no SDL): the driver talks to a backend, so tests use a recorder. */ #ifndef PSX_JOGCON_FFB_H #define PSX_JOGCON_FFB_H @@ -40,4 +46,48 @@ static inline PsxJogconFfb psx_jogcon_ffb_map(uint8_t command, uint8_t strength, return f; } +/* Effect lifetime: a force not renewed within this ends by itself. */ +#define PSX_JOGCON_FFB_LIFETIME_MS 120u + +typedef struct PsxJogconFfbBackend { + void *ctx; + /* Play / renew f (nonzero) for ms milliseconds. */ + void (*play)(void *ctx, const PsxJogconFfb *f, uint32_t ms); + /* Neutral: stop every force now. */ + void (*stop)(void *ctx); +} PsxJogconFfbBackend; + +typedef struct PsxJogconFfbDriver { + PsxJogconFfb last; + int active; /* a force is playing on the backend */ +} PsxJogconFfbDriver; + +/* One VBlank. enabled = title opt-in and a JogCon presented on this port. */ +static inline void psx_jogcon_ffb_tick(PsxJogconFfbDriver *d, + const PsxJogconFfbBackend *b, + int enabled, uint8_t command, + uint8_t strength, int invert) { + PsxJogconFfb f = {0, 0}; + if (!d || !b) return; + if (enabled) f = psx_jogcon_ffb_map(command, strength, invert); + if (f.constant || f.spring) { + if (b->play) b->play(b->ctx, &f, PSX_JOGCON_FFB_LIFETIME_MS); + d->active = 1; + } else if (d->active) { + if (b->stop) b->stop(b->ctx); + d->active = 0; + } + d->last = f; +} + +/* Pause, disconnect, session end: stop now (idempotent). */ +static inline void psx_jogcon_ffb_neutral(PsxJogconFfbDriver *d, + const PsxJogconFfbBackend *b) { + if (!d) return; + if (d->active && b && b->stop) b->stop(b->ctx); + d->active = 0; + d->last.constant = 0; + d->last.spring = 0; +} + #endif /* PSX_JOGCON_FFB_H */ diff --git a/runtime/include/psx_jogcon_ffb_sdl.h b/runtime/include/psx_jogcon_ffb_sdl.h new file mode 100644 index 00000000..c809d1b9 --- /dev/null +++ b/runtime/include/psx_jogcon_ffb_sdl.h @@ -0,0 +1,26 @@ +/* psx_jogcon_ffb_sdl.h - SDL haptic backend for psx_jogcon_ffb.h. */ +#ifndef PSX_JOGCON_FFB_SDL_H +#define PSX_JOGCON_FFB_SDL_H + +#include "psx_jogcon_ffb.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct PsxJogconHaptic { + void *haptic; /* SDL_Haptic*, NULL when the device has no FFB */ + int constant, spring;/* effect ids, -1 when unsupported */ +} PsxJogconHaptic; + +/* Open the haptic device behind an SDL joystick (SDL_Joystick*). Devices + * without constant or spring support (gamepads, virtual joysticks) get none + * and return 0; 1 when at least one effect was created. */ +int psx_jogcon_haptic_open(PsxJogconHaptic *h, void *sdl_joystick); +void psx_jogcon_haptic_close(PsxJogconHaptic *h); /* stops and frees */ +PsxJogconFfbBackend psx_jogcon_haptic_backend(PsxJogconHaptic *h); + +#ifdef __cplusplus +} +#endif +#endif /* PSX_JOGCON_FFB_SDL_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index fb0b1988..e5c3a8d0 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -365,6 +365,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/psx_window_icon.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_sdl_audio.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_stick.c + ${PSXRECOMP_ROOT}/runtime/src/psx_jogcon_ffb_sdl.cpp ${PSXRECOMP_ROOT}/runtime/src/local_mouse_policy.cpp ${PSXRECOMP_ROOT}/runtime/src/local_mouse_sdl.cpp ${PSXRECOMP_ROOT}/runtime/src/memory.c diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 59f8976f..f3fc2deb 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -69,6 +69,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "sio.h" #include "psx_host_rumble.h" #include "psx_jogcon_ffb.h" +#include "psx_jogcon_ffb_sdl.h" #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif @@ -433,15 +434,13 @@ 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}; + /* EXPERIMENTAL JogCon force feedback on this pad's SDL haptics + * ([controller] experimental_jogcon_force_feedback; psx_jogcon_ffb.h). */ + PsxJogconHaptic ffb_haptic = {nullptr, -1, -1}; + PsxJogconFfbDriver ffb = {{0, 0}, 0}; + bool ffb_probed = false; }; -/* game.toml [controller] jogcon_force_feedback(_invert): opt-in. */ +/* game.toml [controller] experimental_jogcon_force_feedback(_invert). */ static bool g_jogcon_ffb = false; static bool g_jogcon_ffb_invert = false; static PlayerInput g_players[PSX_MAX_PLAYERS]; @@ -5195,14 +5194,23 @@ 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; +static PsxJogconFfbBackend player_ffb_backend(PlayerInput& p) { + return psx_jogcon_haptic_backend(&p.ffb_haptic); +} + +/* Pause / disconnect / session end: the wheel goes neutral now. */ +static void jogcon_ffb_neutral_all(void) { + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + PsxJogconFfbBackend b = player_ffb_backend(g_players[s]); + psx_jogcon_ffb_neutral(&g_players[s].ffb, &b); } - p.ffb_constant = p.ffb_spring = -1; +} + +static void close_player_haptic(PlayerInput& p) { + PsxJogconFfbBackend b = player_ffb_backend(p); + psx_jogcon_ffb_neutral(&p.ffb, &b); + psx_jogcon_haptic_close(&p.ffb_haptic); p.ffb_probed = false; - p.ffb_last = PsxJogconFfb{0, 0}; } static void close_player(PlayerInput& p) { @@ -5307,99 +5315,37 @@ 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. */ +/* EXPERIMENTAL JogCon force feedback: the guest's motor command and + * strength drive a host wheel's SDL haptics through psx_jogcon_ffb.h, which + * renews a finite-lifetime force each VBlank and goes neutral otherwise. + * Pads without constant / spring 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) { + const int enabled = g_jogcon_ffb && sio_get_pad_analog(s) == SIO_PAD_JOGCON; + if (enabled) { 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 (!p.ffb_probed && p.handle) { + p.ffb_probed = true; + if (psx_jogcon_haptic_open(&p.ffb_haptic, + SDL_GameControllerGetJoystick(p.handle))) + std::fprintf(stdout, + "psxrecomp runtime: EXPERIMENTAL JogCon force feedback on " + "slot %d (constant %s, spring %s)\n", s + 1, + p.ffb_haptic.constant >= 0 ? "yes" : "no", + p.ffb_haptic.spring >= 0 ? "yes" : "no"); + } } - 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) + const PsxJogconFfb before = p.ffb.last; + PsxJogconFfbBackend b = player_ffb_backend(p); + psx_jogcon_ffb_tick(&p.ffb, &b, enabled, command, strength, + g_jogcon_ffb_invert ? 1 : 0); + if (trace && (before.constant != p.ffb.last.constant || + before.spring != p.ffb.last.spring)) 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; + (int)p.ffb.last.constant, (unsigned)p.ffb.last.spring, + p.ffb_haptic.haptic ? "wheel" : "none"); } static void update_controller_rumble(void) { @@ -8079,6 +8025,7 @@ static void rewind_pause_present(void) { /* Freeze guest in vblank present while the rewind filmstrip is open. */ static void rewind_host_pause_loop(void) { + jogcon_ffb_neutral_all(); /* the wheel goes slack while paused */ psx_local_mouse_begin(sdl_window, false); psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); @@ -8137,6 +8084,7 @@ static void rewind_host_pause_loop(void) { /* Freeze guest in vblank present while the save-state slot menu is open. */ static void savestate_menu_host_pause_loop(void) { + jogcon_ffb_neutral_all(); /* the wheel goes slack while paused */ psx_local_mouse_begin(sdl_window, false); psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); @@ -16735,8 +16683,12 @@ 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; + g_jogcon_ffb = gc.runtime.experimental_jogcon_force_feedback; + g_jogcon_ffb_invert = + gc.runtime.experimental_jogcon_force_feedback_invert; + if (g_jogcon_ffb) + std::fprintf(stdout, "psxrecomp: EXPERIMENTAL JogCon force " + "feedback enabled by game.toml (wheels only)\n"); if (!gc.runtime.direct_shortcut.empty()) apply_title_direct_shortcut(gc.runtime.direct_shortcut, gc.runtime.direct_shortcut_button); diff --git a/runtime/src/psx_jogcon_ffb_sdl.cpp b/runtime/src/psx_jogcon_ffb_sdl.cpp new file mode 100644 index 00000000..cd734cd2 --- /dev/null +++ b/runtime/src/psx_jogcon_ffb_sdl.cpp @@ -0,0 +1,98 @@ +/* psx_jogcon_ffb_sdl.cpp - SDL haptic backend for JogCon force feedback. */ +#include "psx_sdl.h" +#include "psx_jogcon_ffb_sdl.h" + +#include + +static void ffb_effect(SDL_HapticEffect *e, Uint16 type, uint32_t ms) { + std::memset(e, 0, sizeof *e); + e->type = type; + if (type == SDL_HAPTIC_CONSTANT) { + e->constant.direction.type = SDL_HAPTIC_CARTESIAN; + e->constant.direction.dir[0] = 1; + e->constant.length = ms; + } else { + e->condition.direction.type = SDL_HAPTIC_CARTESIAN; + e->condition.length = ms; + e->condition.right_sat[0] = e->condition.left_sat[0] = 0xFFFF; + } +} + +extern "C" int psx_jogcon_haptic_open(PsxJogconHaptic *h, void *joystick) { + if (!h) return 0; + h->haptic = nullptr; + h->constant = h->spring = -1; + if (!joystick) return 0; + /* psx_sdl.h: SDL_InitSubSystem returns 0 on success on SDL2 and SDL3. */ + if (!SDL_WasInit(SDL_INIT_HAPTIC) && + SDL_InitSubSystem(SDL_INIT_HAPTIC) != 0) + return 0; + SDL_Haptic *haptic = SDL_HapticOpenFromJoystick((SDL_Joystick *)joystick); + if (!haptic) return 0; + const unsigned features = (unsigned)SDL_HapticQuery(haptic); + SDL_HapticEffect e; + if (features & SDL_HAPTIC_CONSTANT) { + ffb_effect(&e, SDL_HAPTIC_CONSTANT, PSX_JOGCON_FFB_LIFETIME_MS); + h->constant = SDL_HapticNewEffect(haptic, &e); + } + if (features & SDL_HAPTIC_SPRING) { + ffb_effect(&e, SDL_HAPTIC_SPRING, PSX_JOGCON_FFB_LIFETIME_MS); + h->spring = SDL_HapticNewEffect(haptic, &e); + } + if (h->constant < 0 && h->spring < 0) { + SDL_HapticClose(haptic); + return 0; + } + h->haptic = haptic; + return 1; +} + +extern "C" void psx_jogcon_haptic_close(PsxJogconHaptic *h) { + if (!h) return; + if (h->haptic) { + SDL_HapticStopAll((SDL_Haptic *)h->haptic); + SDL_HapticClose((SDL_Haptic *)h->haptic); + } + h->haptic = nullptr; + h->constant = h->spring = -1; +} + +static void ffb_play(void *ctx, const PsxJogconFfb *f, uint32_t ms) { + PsxJogconHaptic *h = (PsxJogconHaptic *)ctx; + if (!h || !h->haptic || !f) return; + SDL_Haptic *haptic = (SDL_Haptic *)h->haptic; + SDL_HapticEffect e; + if (h->constant >= 0) { + ffb_effect(&e, SDL_HAPTIC_CONSTANT, ms); + e.constant.level = f->constant; + if (f->constant) { + (void)SDL_HapticUpdateEffect(haptic, h->constant, &e); + (void)SDL_HapticRunEffect(haptic, h->constant, 1); + } else { + (void)SDL_HapticStopEffect(haptic, h->constant); + } + } + if (h->spring >= 0) { + ffb_effect(&e, SDL_HAPTIC_SPRING, ms); + e.condition.right_coeff[0] = e.condition.left_coeff[0] = (Sint16)f->spring; + if (f->spring) { + (void)SDL_HapticUpdateEffect(haptic, h->spring, &e); + (void)SDL_HapticRunEffect(haptic, h->spring, 1); + } else { + (void)SDL_HapticStopEffect(haptic, h->spring); + } + } +} + +static void ffb_stop(void *ctx) { + PsxJogconHaptic *h = (PsxJogconHaptic *)ctx; + if (h && h->haptic) SDL_HapticStopAll((SDL_Haptic *)h->haptic); +} + +extern "C" PsxJogconFfbBackend psx_jogcon_haptic_backend(PsxJogconHaptic *h) { + PsxJogconFfbBackend b; + b.ctx = h; + b.play = ffb_play; + b.stop = ffb_stop; + return b; +} diff --git a/runtime/tests/test_jogcon_ffb.c b/runtime/tests/test_jogcon_ffb.c index 7842ae27..7659c221 100644 --- a/runtime/tests/test_jogcon_ffb.c +++ b/runtime/tests/test_jogcon_ffb.c @@ -3,6 +3,13 @@ #include "psx_jogcon_ffb.h" static int s_fail; +static int s_plays, s_stops; +static uint32_t s_ms; +static PsxJogconFfb s_last; +static void rec_play(void *c, const PsxJogconFfb *f, uint32_t ms) { + (void)c; s_plays++; s_ms = ms; s_last = *f; +} +static void rec_stop(void *c) { (void)c; s_stops++; } #define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL %s\n", m); ++s_fail; } } while (0) int main(void) { @@ -30,6 +37,38 @@ int main(void) { 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"); + { /* driver */ + PsxJogconFfbBackend b = { NULL, rec_play, rec_stop }; + PsxJogconFfbDriver d = {{0, 0}, 0}; + psx_jogcon_ffb_tick(&d, &b, 1, 0, 0, 0); + CHECK(s_plays == 0 && s_stops == 0, "idle: nothing sent"); + psx_jogcon_ffb_tick(&d, &b, 1, 3, 9, 0); + CHECK(s_plays == 1 && s_last.spring == 9 * 32767 / 15 && d.active, + "hold plays a spring"); + CHECK(s_ms == PSX_JOGCON_FFB_LIFETIME_MS && s_ms > 0 && s_ms < 1000, + "every force has a short finite lifetime (stall -> slack)"); + psx_jogcon_ffb_tick(&d, &b, 1, 3, 9, 0); + CHECK(s_plays == 2, "renewed every VBlank"); + psx_jogcon_ffb_tick(&d, &b, 1, 0, 0, 0); + CHECK(s_stops == 1 && !d.active, "guest stop -> backend stop once"); + psx_jogcon_ffb_tick(&d, &b, 1, 0, 0, 0); + CHECK(s_stops == 1, "no repeated stops while idle"); + /* pause / disconnect / session end */ + psx_jogcon_ffb_tick(&d, &b, 1, 1, 15, 0); + psx_jogcon_ffb_neutral(&d, &b); + CHECK(s_stops == 2 && !d.active && !d.last.constant, "neutral stops a live force"); + psx_jogcon_ffb_neutral(&d, &b); + CHECK(s_stops == 2, "neutral is idempotent"); + /* opt-in off or JogCon no longer presented */ + psx_jogcon_ffb_tick(&d, &b, 1, 2, 15, 0); + psx_jogcon_ffb_tick(&d, &b, 0, 2, 15, 0); + CHECK(s_stops == 3 && !d.active, "disabled mid-force -> stop"); + { /* null backend callbacks are safe */ + PsxJogconFfbBackend n = { NULL, NULL, NULL }; + psx_jogcon_ffb_tick(&d, &n, 1, 1, 15, 0); + psx_jogcon_ffb_neutral(&d, &n); + } + } if (s_fail) return 1; puts("JogCon force feedback tests passed"); return 0; diff --git a/runtime/tests/test_jogcon_ffb_sdl.cpp b/runtime/tests/test_jogcon_ffb_sdl.cpp new file mode 100644 index 00000000..71dbb30f --- /dev/null +++ b/runtime/tests/test_jogcon_ffb_sdl.cpp @@ -0,0 +1,64 @@ +/* JogCon force feedback SDL backend against SDL's virtual joystick. + * + * SDL has no virtual haptic device: SDL3 virtual joysticks expose rumble, + * trigger rumble, LED and sensors, never SDL_Haptic effects, and no software + * haptic driver exists. So this checks what SDL can represent: a virtual + * wheel-type joystick with rumble is NOT driven by the force-feedback path + * (gamepads keep their rumble path), the backend calls are safe no-ops on it, + * and close / neutral leave nothing running. Real constant / spring output + * needs a physical force-feedback wheel. Exit 77 = skipped (SDL2 build). */ +#include "psx_sdl.h" +#include "psx_jogcon_ffb_sdl.h" + +#include + +static int s_fail; +#define CHECK(c, m) do { if (!(c)) { std::fprintf(stderr, "FAIL %s\n", m); ++s_fail; } } while (0) + +#if defined(PSX_SDL3) +static int s_rumbles; +static bool SDLCALL virt_rumble(void*, Uint16, Uint16) { ++s_rumbles; return true; } + +int main() { + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "0"); + if (SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD) != 0) { /* psx_sdl.h: 0 = ok */ + std::printf("skip: SDL_Init: %s\n", SDL_GetError()); + return 77; + } + SDL_VirtualJoystickDesc desc; + SDL_INIT_INTERFACE(&desc); + desc.type = SDL_JOYSTICK_TYPE_WHEEL; + desc.naxes = 6; + desc.nbuttons = 15; + desc.name = "psxrecomp virtual wheel"; + desc.Rumble = virt_rumble; + const SDL_JoystickID id = SDL_AttachVirtualJoystick(&desc); + CHECK(id != 0, "virtual wheel attached"); + SDL_Joystick* joy = id ? SDL_OpenJoystick(id) : nullptr; + CHECK(joy != nullptr, "virtual wheel opened"); + + PsxJogconHaptic h; + const int opened = psx_jogcon_haptic_open(&h, joy); + CHECK(opened == 0 && h.haptic == nullptr && h.constant < 0 && h.spring < 0, + "no SDL haptic behind a virtual joystick: FFB path stays off"); + PsxJogconFfbBackend b = psx_jogcon_haptic_backend(&h); + PsxJogconFfbDriver d = {{0, 0}, 0}; + psx_jogcon_ffb_tick(&d, &b, 1, 3, 15, 0); /* plays into no device */ + psx_jogcon_ffb_neutral(&d, &b); + CHECK(s_rumbles == 0, "FFB never falls back to gamepad rumble"); + CHECK(SDL_RumbleJoystick(joy, 0x4000, 0x4000, 50) && s_rumbles == 1, + "the virtual device itself is live (rumble reaches it)"); + psx_jogcon_haptic_close(&h); + psx_jogcon_haptic_close(&h); /* idempotent */ + CHECK(psx_jogcon_haptic_open(&h, nullptr) == 0, "null joystick is safe"); + + if (joy) SDL_CloseJoystick(joy); + if (id) SDL_DetachVirtualJoystick(id); + SDL_Quit(); + if (s_fail) return 1; + std::puts("JogCon FFB SDL (virtual joystick) tests passed"); + return 0; +} +#else +int main() { std::puts("skip: needs SDL3 virtual joysticks"); return 77; } +#endif From 2d47dabfa23624f51c664026d4a4bdf189f97a0f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:02:17 -0700 Subject: [PATCH 3/4] JogCon FFB: player setting, off by default, live-applicable Owner decision: wheel force feedback is an optional player setting, off by default. settings.toml [controller] jogcon_force_feedback (UserSettings, load + save) overrides the title's game.toml experimental_jogcon_force_feedback default. psx_jogcon_ffb_set_player_enabled applies it live (off -> every wheel neutral now); the launcher's boot and lobby soft-return exits seed and read RecompLauncherCSettings .jogcon_force_feedback (RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK, RetroPortingToolKit/recomp-ui#95) and persist it. Tests: jogcon_ffb_settings_test (default off, on, explicit off, round-trip); jogcon_ffb_test (setting off: no haptic calls; toggled on: the hold drives; off live: neutral). Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 11 ++- recompiler/CMakeLists.txt | 13 ++++ recompiler/src/config_loader.cpp | 9 ++- recompiler/src/config_loader.h | 4 ++ recompiler/tests/jogcon_ffb_settings_test.cpp | 68 +++++++++++++++++++ runtime/include/psx_jogcon_ffb_sdl.h | 7 ++ runtime/src/main.cpp | 41 ++++++++++- runtime/tests/test_jogcon_ffb.c | 12 ++++ 8 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 recompiler/tests/jogcon_ffb_settings_test.cpp diff --git a/docs/config_schema.md b/docs/config_schema.md index d83251af..cc24bfae 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -867,9 +867,14 @@ pad with the louder of that and the guest's SIO motors; a value lapses 8 VBlanks after the last call and all are cleared at every mod/session reset. `PSX_RUMBLE_TRACE=1` logs every change, headless included. -**EXPERIMENTAL:** `[controller] experimental_jogcon_force_feedback = true` -drives a host force-feedback wheel's SDL haptics from a presented JogCon's -motor commands: a constant force for turn commands 1 and 2, and a centring +**EXPERIMENTAL:** JogCon wheel force feedback is a player setting, +`settings.toml [controller] jogcon_force_feedback` (off by default; the +launcher / in-game menu toggle writes it via +`RecompLauncherCSettings.jogcon_force_feedback`, applied live through +`psx_jogcon_ffb_set_player_enabled`). A title may set its default with +`game.toml [controller] experimental_jogcon_force_feedback` (off when +absent); the player's setting always wins. When on, it drives a host +force-feedback wheel's SDL haptics from a presented JogCon's motor commands: a constant force for turn commands 1 and 2, and a centring spring for hold. It is off by default. Gamepads and devices without constant or spring haptics are never driven. Every force has a 120 ms lifetime and is renewed each VBlank, so a pause, stall or crash leaves the wheel slack. The diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 7058f495..0a9e3832 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -781,6 +781,19 @@ if(BUILD_TESTING) ) target_link_libraries(rewind_settings_test PRIVATE fmt) add_test(NAME rewind_settings_test COMMAND rewind_settings_test) + add_executable(jogcon_ffb_settings_test + tests/jogcon_ffb_settings_test.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp + ) + target_include_directories(jogcon_ffb_settings_test PRIVATE + include + src + lib/fmt/include + lib/toml11 + ) + target_link_libraries(jogcon_ffb_settings_test PRIVATE fmt) + add_test(NAME jogcon_ffb_settings_test COMMAND jogcon_ffb_settings_test) # Launcher-saved paths stay relative inside a portable game folder. add_executable(portable_settings_paths_test diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 9d72ed21..bdb156ff 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -3199,6 +3199,10 @@ UserSettings load_user_settings(const fs::path& path) { s.multitap_analog = toml::find(ct, "multitap_analog"); s.has_multitap_analog = true; }); + if (ct.contains("jogcon_force_feedback")) try_get([&]{ + s.jogcon_force_feedback = toml::find(ct, "jogcon_force_feedback"); + s.has_jogcon_force_feedback = true; + }); if (ct.contains("deadzone")) try_get([&]{ const auto n = toml::find(ct, "deadzone"); if (n >= 0 && n <= 32767) { @@ -3395,7 +3399,7 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { { bool any_ctrl = s.has_deadzone || s.has_multitap_enabled || - s.has_multitap_analog; + s.has_multitap_analog || s.has_jogcon_force_feedback; for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) { if (s.has_p_device[i] || s.has_p_mode[i] || s.has_p_deadzone[i]) any_ctrl = true; @@ -3424,6 +3428,9 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_multitap_analog) f << "multitap_analog = " << (s.multitap_analog ? "true" : "false") << "\n"; + if (s.has_jogcon_force_feedback) + f << "jogcon_force_feedback = " + << (s.jogcon_force_feedback ? "true" : "false") << "\n"; /* Keep a global deadzone= for older readers (mirrors P1). */ if (s.has_deadzone || s.has_p_deadzone[0]) f << "deadzone = " diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 27d803ae..d7d1c716 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -1627,6 +1627,10 @@ struct UserSettings { // DualShock-on-tap hack (settings.toml [controller] multitap_analog). // Default on when unset; game.toml / global prefs may override. bool has_multitap_analog = false; bool multitap_analog = true; + // EXPERIMENTAL JogCon wheel force feedback, the player's choice + // (settings.toml [controller] jogcon_force_feedback). Unset = the title's + // game.toml experimental_jogcon_force_feedback default (off). + bool has_jogcon_force_feedback = false; bool jogcon_force_feedback = false; // Localization: the launcher's chosen language code (feeds RuntimeConfig // .language / g_lang). "off"/"jp"/"" = untranslated native game. Persisted to // settings.toml [localization].language. diff --git a/recompiler/tests/jogcon_ffb_settings_test.cpp b/recompiler/tests/jogcon_ffb_settings_test.cpp new file mode 100644 index 00000000..8b838851 --- /dev/null +++ b/recompiler/tests/jogcon_ffb_settings_test.cpp @@ -0,0 +1,68 @@ +/* jogcon_ffb_settings_test — EXPERIMENTAL wheel force feedback is a player + * setting, off by default (settings.toml [controller] jogcon_force_feedback). + * + * 1. a fresh UserSettings and an absent key mean off and "no opinion", so + * the title's game.toml default (off) applies; + * 2. settings.toml turns it on and can spell an explicit off; + * 3. save_user_settings round-trips it beside the other [controller] keys; + * 4. the game.toml key is experimental_jogcon_force_feedback, off when + * absent. */ +#include "config_loader.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static int failures = 0; +static void check(bool cond, const char* what) { + if (!cond) { std::fprintf(stderr, "FAIL: %s\n", what); ++failures; } +} +static fs::path write_temp(const std::string& name, const std::string& body) { + fs::path p = fs::temp_directory_path() / name; + std::ofstream f(p, std::ios::binary | std::ios::trunc); + f << body; + return p; +} + +int main() { + PSXRecompV4::UserSettings fresh; + check(!fresh.jogcon_force_feedback && !fresh.has_jogcon_force_feedback, + "fresh: off, no opinion"); + + fs::path p = write_temp("psxrecomp_ffb_absent.toml", "[controller]\np1_mode = \"analog\"\n"); + auto us = PSXRecompV4::load_user_settings(p); + check(!us.parse_error && !us.has_jogcon_force_feedback && !us.jogcon_force_feedback, + "absent key: off, no opinion"); + fs::remove(p); + + p = write_temp("psxrecomp_ffb_on.toml", "[controller]\njogcon_force_feedback = true\n"); + us = PSXRecompV4::load_user_settings(p); + check(us.has_jogcon_force_feedback && us.jogcon_force_feedback, "settings.toml turns it on"); + fs::remove(p); + + p = write_temp("psxrecomp_ffb_off.toml", "[controller]\njogcon_force_feedback = false\n"); + us = PSXRecompV4::load_user_settings(p); + check(us.has_jogcon_force_feedback && !us.jogcon_force_feedback, "explicit off is kept"); + fs::remove(p); + + { + PSXRecompV4::UserSettings s; + s.jogcon_force_feedback = true; s.has_jogcon_force_feedback = true; + s.multitap_analog = false; s.has_multitap_analog = true; + fs::path out = fs::temp_directory_path() / "psxrecomp_ffb_roundtrip.toml"; + check(PSXRecompV4::save_user_settings(out, s), "save"); + auto r = PSXRecompV4::load_user_settings(out); + check(r.has_jogcon_force_feedback && r.jogcon_force_feedback, + "round-trip keeps the setting"); + check(r.has_multitap_analog && !r.multitap_analog, + "round-trip keeps the other [controller] keys"); + fs::remove(out); + } + + if (failures) { std::fprintf(stderr, "%d failure(s)\n", failures); return 1; } + std::puts("jogcon_ffb_settings_test: ok"); + return 0; +} diff --git a/runtime/include/psx_jogcon_ffb_sdl.h b/runtime/include/psx_jogcon_ffb_sdl.h index c809d1b9..208e9eb6 100644 --- a/runtime/include/psx_jogcon_ffb_sdl.h +++ b/runtime/include/psx_jogcon_ffb_sdl.h @@ -20,6 +20,13 @@ int psx_jogcon_haptic_open(PsxJogconHaptic *h, void *sdl_joystick); void psx_jogcon_haptic_close(PsxJogconHaptic *h); /* stops and frees */ PsxJogconFfbBackend psx_jogcon_haptic_backend(PsxJogconHaptic *h); +/* Runtime (main.cpp): the player's EXPERIMENTAL wheel force-feedback setting + * (settings.toml [controller] jogcon_force_feedback, default off), live. A + * settings path (launcher exit, the in-game menu's apply_settings) calls + * this with RecompLauncherCSettings.jogcon_force_feedback and persists it. */ +void psx_jogcon_ffb_set_player_enabled(int on); +int psx_jogcon_ffb_player_enabled(void); + #ifdef __cplusplus } #endif diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index f3fc2deb..76a70b3d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -5206,6 +5206,20 @@ static void jogcon_ffb_neutral_all(void) { } } +/* The player's EXPERIMENTAL wheel force-feedback setting, live: takes effect + * on the next VBlank; turning it off makes every wheel neutral now. Callers + * (launcher exits, the in-game menu's settings apply) also persist it to + * settings.toml [controller] jogcon_force_feedback. */ +extern "C" void psx_jogcon_ffb_set_player_enabled(int on) { + const bool was = g_jogcon_ffb; + g_jogcon_ffb = on != 0; + if (was && !g_jogcon_ffb) jogcon_ffb_neutral_all(); + if (was != g_jogcon_ffb) + std::fprintf(stdout, "psxrecomp: EXPERIMENTAL JogCon wheel force " + "feedback %s (player setting)\n", g_jogcon_ffb ? "on" : "off"); +} +extern "C" int psx_jogcon_ffb_player_enabled(void) { return g_jogcon_ffb ? 1 : 0; } + static void close_player_haptic(PlayerInput& p) { PsxJogconFfbBackend b = player_ffb_backend(p); psx_jogcon_ffb_neutral(&p.ffb, &b); @@ -16688,7 +16702,8 @@ int main(int argc, char** argv) { gc.runtime.experimental_jogcon_force_feedback_invert; if (g_jogcon_ffb) std::fprintf(stdout, "psxrecomp: EXPERIMENTAL JogCon force " - "feedback enabled by game.toml (wheels only)\n"); + "feedback on by the title's default (wheels " + "only; the player setting overrides)\n"); if (!gc.runtime.direct_shortcut.empty()) apply_title_direct_shortcut(gc.runtime.direct_shortcut, gc.runtime.direct_shortcut_button); @@ -17016,6 +17031,8 @@ int main(int argc, char** argv) { if (us.has_memcard1_enabled) memcard1_enabled = us.memcard1_enabled; if (us.has_memcard2_enabled) memcard2_enabled = us.memcard2_enabled; if (us.has_multitap_enabled) multitap_enabled = us.multitap_enabled; + if (us.has_jogcon_force_feedback) + psx_jogcon_ffb_set_player_enabled(us.jogcon_force_feedback ? 1 : 0); if (us.has_multitap_analog) { multitap_analog = us.multitap_analog; sio_set_multitap_analog(multitap_analog ? 1 : 0); @@ -17791,6 +17808,9 @@ int main(int argc, char** argv) { ls.multitap_analog = seed.multitap_analog ? 1 : 0; g_lnch_multitap_analog = seed.multitap_analog ? 1 : 0; #endif +#if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) + ls.jogcon_force_feedback = psx_jogcon_ffb_player_enabled(); +#endif /* Region badge: game.toml [game] region wins verbatim; otherwise derive * from the game_id serial prefix (SCUS/SLUS/LSP -> USA, SCES/SLES -> @@ -18168,6 +18188,14 @@ int main(int argc, char** argv) { (void)PSXRecompV4::upsert_game_toml_controller_bool( game_config_path, "multitap_analog", multitap_analog); } +#endif +#if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) + if ((ls.jogcon_force_feedback != 0) != (psx_jogcon_ffb_player_enabled() != 0) || + seed.has_jogcon_force_feedback) { + seed.jogcon_force_feedback = ls.jogcon_force_feedback != 0; + seed.has_jogcon_force_feedback = true; + } + psx_jogcon_ffb_set_player_enabled(ls.jogcon_force_feedback); #endif if (ls.memcard_path[0][0]) { seed.memcard1_path = ls.memcard_path[0]; seed.has_memcard1_path = true; } if (ls.memcard_path[1][0]) { seed.memcard2_path = ls.memcard_path[1]; seed.has_memcard2_path = true; } @@ -20308,6 +20336,9 @@ soft_return_lobby: #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) ls.multitap_analog = multitap_analog ? 1 : 0; g_lnch_multitap_analog = multitap_analog ? 1 : 0; +#endif +#if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) + ls.jogcon_force_feedback = psx_jogcon_ffb_player_enabled(); #endif /* Preserve input devices across soft-return. A zeroed ls left every * player_src at None, so the rematch UI (and a subsequent writeback) @@ -20560,6 +20591,14 @@ soft_return_lobby: us.has_multitap_enabled = true; apply_offline_pad_count(game_players, multitap_enabled); #endif +#if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) + if ((ls.jogcon_force_feedback != 0) != (psx_jogcon_ffb_player_enabled() != 0) || + us.has_jogcon_force_feedback) { + us.jogcon_force_feedback = ls.jogcon_force_feedback != 0; + us.has_jogcon_force_feedback = true; + } + psx_jogcon_ffb_set_player_enabled(ls.jogcon_force_feedback); +#endif #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) multitap_analog = ls.multitap_analog != 0; us.multitap_analog = multitap_analog; diff --git a/runtime/tests/test_jogcon_ffb.c b/runtime/tests/test_jogcon_ffb.c index 7659c221..615f2297 100644 --- a/runtime/tests/test_jogcon_ffb.c +++ b/runtime/tests/test_jogcon_ffb.c @@ -37,6 +37,18 @@ int main(void) { 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"); + { /* player setting off (the default): R4's hold commands send nothing */ + PsxJogconFfbBackend b = { NULL, rec_play, rec_stop }; + PsxJogconFfbDriver d = {{0, 0}, 0}; + for (uint8_t st = 1; st <= 15; ++st) psx_jogcon_ffb_tick(&d, &b, 0, 3, st, 0); + CHECK(s_plays == 0 && s_stops == 0 && !d.active, + "setting off: no haptic calls at all"); + psx_jogcon_ffb_tick(&d, &b, 1, 3, 4, 0); + CHECK(s_plays == 1 && d.active, "toggled on: the hold drives the wheel"); + psx_jogcon_ffb_tick(&d, &b, 0, 3, 4, 0); + CHECK(s_stops == 1 && !d.active, "toggled off live: neutral now"); + s_plays = s_stops = 0; + } { /* driver */ PsxJogconFfbBackend b = { NULL, rec_play, rec_stop }; PsxJogconFfbDriver d = {{0, 0}, 0}; From 2d3e3f69b57532463ba8b5c0d9926c03c126f10b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:07:11 -0700 Subject: [PATCH 4/4] JogCon FFB: provider-neutral setting plumbing The settings field lives in recomp-launcher's header (R4's provider); the runtime reads it only behind RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK, so a recomp-ui build compiles unchanged. PSX_HAS_JOGCON_FFB_PLAYER_SETTING lets the in-game overlay's apply_settings call the live hook without depending on this PR. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 3 ++- runtime/include/psx_jogcon_ffb_sdl.h | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/config_schema.md b/docs/config_schema.md index cc24bfae..8ea8d4fa 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -870,7 +870,8 @@ VBlanks after the last call and all are cleared at every mod/session reset. **EXPERIMENTAL:** JogCon wheel force feedback is a player setting, `settings.toml [controller] jogcon_force_feedback` (off by default; the launcher / in-game menu toggle writes it via -`RecompLauncherCSettings.jogcon_force_feedback`, applied live through +`RecompLauncherCSettings.jogcon_force_feedback` (recomp-launcher; read behind +`RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK`, so other providers still build), applied live through `psx_jogcon_ffb_set_player_enabled`). A title may set its default with `game.toml [controller] experimental_jogcon_force_feedback` (off when absent); the player's setting always wins. When on, it drives a host diff --git a/runtime/include/psx_jogcon_ffb_sdl.h b/runtime/include/psx_jogcon_ffb_sdl.h index 208e9eb6..962cfcb3 100644 --- a/runtime/include/psx_jogcon_ffb_sdl.h +++ b/runtime/include/psx_jogcon_ffb_sdl.h @@ -20,10 +20,16 @@ int psx_jogcon_haptic_open(PsxJogconHaptic *h, void *sdl_joystick); void psx_jogcon_haptic_close(PsxJogconHaptic *h); /* stops and frees */ PsxJogconFfbBackend psx_jogcon_haptic_backend(PsxJogconHaptic *h); +/* Lets other host code (e.g. the in-game overlay's apply_settings) detect + * the hook below without depending on this header's revision. */ +#define PSX_HAS_JOGCON_FFB_PLAYER_SETTING 1 + /* Runtime (main.cpp): the player's EXPERIMENTAL wheel force-feedback setting * (settings.toml [controller] jogcon_force_feedback, default off), live. A * settings path (launcher exit, the in-game menu's apply_settings) calls - * this with RecompLauncherCSettings.jogcon_force_feedback and persists it. */ + * this with RecompLauncherCSettings.jogcon_force_feedback (recomp-launcher's + * header; read only behind RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK, so + * any launcher provider builds) and persists it. */ void psx_jogcon_ffb_set_player_enabled(int on); int psx_jogcon_ffb_player_enabled(void);