Integrate merged experimental wheel support before preset validation

This commit is contained in:
Matthew Stanley
2026-10-15 13:49:46 -07:00
17 changed files with 663 additions and 2 deletions
+18
View File
@@ -1020,6 +1020,24 @@ 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:** 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` (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
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
+1 -1
View File
@@ -1 +1 @@
2065825edaf605d848ea48964c0cc1af53f07f937db57b9cb601df7fe130c2c9
eccaa6de7bfea72a91022053298870d0e7e468ac4c5dc67d4a0cea45a8872177
+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)
# Graphics presets: [quality.*] parsing, detection rules, the
# detect/keep/Custom decision and settings round trip.
+14 -1
View File
@@ -1115,6 +1115,12 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
rt.direct_shortcut_button =
toml::find<std::string>(ct, "direct_shortcut_button");
}
if (ct.contains("experimental_jogcon_force_feedback"))
rt.experimental_jogcon_force_feedback =
toml::find<bool>(ct, "experimental_jogcon_force_feedback");
if (ct.contains("experimental_jogcon_force_feedback_invert"))
rt.experimental_jogcon_force_feedback_invert =
toml::find<bool>(ct, "experimental_jogcon_force_feedback_invert");
if (ct.contains("anti_deadzone")) {
const auto n = toml::find<int64_t>(ct, "anti_deadzone");
if (n < 0 || n > 32767)
@@ -3329,6 +3335,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) {
@@ -3533,7 +3543,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;
@@ -3562,6 +3572,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 = "
+11
View File
@@ -830,6 +830,13 @@ struct RuntimeConfig {
std::string direct_shortcut;
std::string direct_shortcut_button;
// 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),
// tap seats stay plain digital. Overridable by settings.toml / match_caps.
@@ -1680,6 +1687,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;
}
+14
View File
@@ -765,6 +765,20 @@ 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)
# 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
$<$<BOOL:${PSX_SDL3}>: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)
+93
View File
@@ -0,0 +1,93 @@
/* 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). 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
#include <stdint.h>
#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;
}
/* 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 */
+39
View File
@@ -0,0 +1,39 @@
/* 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);
/* 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 (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);
#ifdef __cplusplus
}
#endif
#endif /* PSX_JOGCON_FFB_SDL_H */
+3
View File
@@ -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. */
+1
View File
@@ -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
+111
View File
@@ -71,6 +71,8 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val;
#include "latency_ring.h"
#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
@@ -440,7 +442,15 @@ struct PlayerInput {
bool rumble_known = false;
bool rumble_warned = false;
bool steering_wheel = false;
/* 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] 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];
/* Offline SIO sample loop bound (from game.toml players; clamped). */
static int g_offline_pad_count = 2;
@@ -5359,7 +5369,41 @@ static void load_input_config(const char* argv0) {
psx_keybinds_init(argv0);
}
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);
}
}
/* 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);
psx_jogcon_haptic_close(&p.ffb_haptic);
p.ffb_probed = false;
}
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);
@@ -5460,6 +5504,39 @@ 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. */
/* 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) {
uint8_t command = 0, strength = 0;
const int enabled = g_jogcon_ffb && sio_get_pad_analog(s) == SIO_PAD_JOGCON;
if (enabled) {
sio_get_pad_jogcon_motor(s, &command, &strength);
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");
}
}
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)p.ffb.last.constant, (unsigned)p.ffb.last.spring,
p.ffb_haptic.haptic ? "wheel" : "none");
}
static void update_controller_rumble(void) {
static const bool trace = [] {
const char* e = std::getenv("PSX_RUMBLE_TRACE");
@@ -5468,6 +5545,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);
@@ -8116,6 +8194,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);
@@ -8281,6 +8360,7 @@ static int host_pause_refuse_netplay(void) { return psx_netplay_active() ? 1 : 0
/* 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);
@@ -17662,6 +17742,13 @@ 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.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 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);
@@ -17993,6 +18080,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);
@@ -18796,6 +18885,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 ->
@@ -19177,6 +19269,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; }
@@ -21337,6 +21437,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)
@@ -21589,6 +21692,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;
+98
View File
@@ -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 <cstring>
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;
}
+20
View File
@@ -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)
@@ -3140,6 +3158,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;
@@ -3156,6 +3175,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;
}
+87
View File
@@ -0,0 +1,87 @@
/* psx_jogcon_ffb.h: JogCon motor byte -> wheel constant force / spring. */
#include <stdio.h>
#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) {
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");
{ /* 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};
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;
}
+64
View File
@@ -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 <cstdio>
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
+8
View File
@@ -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);