mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user