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] 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};