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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-12 01:02:17 -07:00
parent 05931590a3
commit 2d47dabfa2
8 changed files with 160 additions and 5 deletions
+8 -3
View File
@@ -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
+13
View File
@@ -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
+8 -1
View File
@@ -3199,6 +3199,10 @@ UserSettings load_user_settings(const fs::path& path) {
s.multitap_analog = toml::find<bool>(ct, "multitap_analog");
s.has_multitap_analog = true;
});
if (ct.contains("jogcon_force_feedback")) try_get([&]{
s.jogcon_force_feedback = toml::find<bool>(ct, "jogcon_force_feedback");
s.has_jogcon_force_feedback = true;
});
if (ct.contains("deadzone")) try_get([&]{
const auto n = toml::find<int64_t>(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 = "
+4
View File
@@ -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.
@@ -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 <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
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;
}
+7
View File
@@ -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
+40 -1
View File
@@ -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;
+12
View File
@@ -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};