controller: hybrid pad mode (auto analog/digital) + 3-way launcher selector

Replaces the analog on/off boolean with a per-player PadMode {hybrid, analog,
digital}. Hybrid (default) auto-switches the reported pad from the most-recent
input: nudge the stick -> DualShock (0x73, variable sticks); press the D-pad ->
digital pad (0x41) so the game runs its own digital path at true d-pad
sensitivity -- mirroring a DualShock's analog LED and Tomba Special Edition's
auto-detect. analog pins 0x73; digital pins 0x41.

config_loader parses [controller] default_mode / p1_mode / p2_mode (legacy
default_analog / p1_analog / p2_analog booleans still accepted); the launcher
exposes a Hybrid|Analog|D-Pad segmented selector per player with a tooltip;
settings persist as p1_mode / p2_mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-17 09:03:31 -07:00
parent eec3d7893d
commit e0410e95fe
5 changed files with 258 additions and 89 deletions
+74 -16
View File
@@ -17,6 +17,27 @@ namespace PSXRecompV4 {
namespace fs = std::filesystem;
// Pad mode <-> string. Accepts "hybrid"/"analog"/"digital" (case-insensitive);
// returns `fallback` for anything unrecognised so a typo never silently flips
// the pad type.
int pad_mode_from_string(const std::string& s, int fallback) {
std::string l;
l.reserve(s.size());
for (char c : s) l.push_back((char)std::tolower((unsigned char)c));
if (l == "hybrid") return PAD_MODE_HYBRID;
if (l == "analog") return PAD_MODE_ANALOG;
if (l == "digital") return PAD_MODE_DIGITAL;
return fallback;
}
const char* pad_mode_to_string(int mode) {
switch (mode) {
case PAD_MODE_ANALOG: return "analog";
case PAD_MODE_DIGITAL: return "digital";
default: return "hybrid";
}
}
// Parse a hex string ("0x...") to uint32_t. Throws on malformed input.
static uint32_t parse_hex(const std::string& s, const std::string& field) {
try {
@@ -176,19 +197,40 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
// Optional [controller] block — game-declared input defaults.
if (cfg.contains("controller")) {
const toml::value& ct = toml::find(cfg, "controller");
// Legacy boolean form (true->analog, false->digital), read first so the
// new string `*_mode` keys win when both are present.
if (ct.contains("default_analog")) {
const bool a = toml::find<bool>(ct, "default_analog");
rt.default_p1_analog = a;
rt.default_p2_analog = a;
rt.has_default_analog = true;
const int m = toml::find<bool>(ct, "default_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
rt.default_p1_mode = rt.default_p2_mode = m;
rt.has_default_mode = true;
}
if (ct.contains("p1_analog")) {
rt.default_p1_analog = toml::find<bool>(ct, "p1_analog");
rt.has_default_analog = true;
rt.default_p1_mode = toml::find<bool>(ct, "p1_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
rt.has_default_mode = true;
}
if (ct.contains("p2_analog")) {
rt.default_p2_analog = toml::find<bool>(ct, "p2_analog");
rt.has_default_analog = true;
rt.default_p2_mode = toml::find<bool>(ct, "p2_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
rt.has_default_mode = true;
}
// Preferred string form.
if (ct.contains("default_mode")) {
const int m = pad_mode_from_string(
toml::find<std::string>(ct, "default_mode"), PAD_MODE_HYBRID);
rt.default_p1_mode = rt.default_p2_mode = m;
rt.has_default_mode = true;
}
if (ct.contains("p1_mode")) {
rt.default_p1_mode = pad_mode_from_string(
toml::find<std::string>(ct, "p1_mode"), PAD_MODE_HYBRID);
rt.has_default_mode = true;
}
if (ct.contains("p2_mode")) {
rt.default_p2_mode = pad_mode_from_string(
toml::find<std::string>(ct, "p2_mode"), PAD_MODE_HYBRID);
rt.has_default_mode = true;
}
if (ct.contains("deadzone")) {
const auto n = toml::find<int64_t>(ct, "deadzone");
@@ -741,11 +783,27 @@ UserSettings load_user_settings(const fs::path& path) {
const auto v = toml::find<std::string>(ct, "p2_device");
if (!v.empty()) { s.p2_device = v; s.has_p2_device = true; }
});
// Legacy boolean form first (true->analog, false->digital); the new
// string `*_mode` keys override when present.
if (ct.contains("p1_analog")) try_get([&]{
s.p1_analog = toml::find<bool>(ct, "p1_analog"); s.has_p1_analog = true;
s.p1_mode = toml::find<bool>(ct, "p1_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
s.has_p1_mode = true;
});
if (ct.contains("p2_analog")) try_get([&]{
s.p2_analog = toml::find<bool>(ct, "p2_analog"); s.has_p2_analog = true;
s.p2_mode = toml::find<bool>(ct, "p2_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
s.has_p2_mode = true;
});
if (ct.contains("p1_mode")) try_get([&]{
s.p1_mode = pad_mode_from_string(
toml::find<std::string>(ct, "p1_mode"), PAD_MODE_HYBRID);
s.has_p1_mode = true;
});
if (ct.contains("p2_mode")) try_get([&]{
s.p2_mode = pad_mode_from_string(
toml::find<std::string>(ct, "p2_mode"), PAD_MODE_HYBRID);
s.has_p2_mode = true;
});
if (ct.contains("deadzone")) try_get([&]{
const auto n = toml::find<int64_t>(ct, "deadzone");
@@ -817,19 +875,19 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
f << "enable2 = " << (s.memcard2_enabled ? "true" : "false") << "\n";
}
if (s.has_p1_device || s.has_p2_device || s.has_p1_analog || s.has_p2_analog ||
if (s.has_p1_device || s.has_p2_device || s.has_p1_mode || s.has_p2_mode ||
s.has_deadzone) {
f << "\n[controller]\n";
if (s.has_p1_device)
f << "p1_device = \"" << s.p1_device << "\"\n";
if (s.has_p1_analog)
f << "p1_analog = " << (s.p1_analog ? "true" : "false") << "\n";
if (s.has_p1_mode)
f << "p1_mode = \"" << pad_mode_to_string(s.p1_mode) << "\"\n";
if (s.has_p2_device)
f << "p2_device = \"" << s.p2_device << "\"\n";
if (s.has_p2_analog)
f << "p2_analog = " << (s.p2_analog ? "true" : "false") << "\n";
if (s.has_p2_mode)
f << "p2_mode = \"" << pad_mode_to_string(s.p2_mode) << "\"\n";
if (s.has_deadzone)
f << "deadzone = " << s.deadzone << "\n";
f << "deadzone = " << s.deadzone << "\n";
}
return f.good();
+36 -13
View File
@@ -25,6 +25,23 @@
namespace PSXRecompV4 {
// Pad input mode (per player). Replaces the old analog on/off boolean.
// hybrid — auto-switch DualShock(analog)/digital per the most-recent input:
// nudge the stick -> report DualShock (0x73, variable sticks);
// press the D-pad -> report a digital pad (0x41) so the game runs
// its OWN d-pad path at true digital sensitivity. Mirrors a
// DualShock's analog LED toggling on/off (and Tomba Special
// Edition's auto-detect). Default.
// analog — always present a DualShock/analog pad (id 0x73). The D-pad is
// folded onto the stick at full deflection so it still moves you.
// digital — always present a digital pad (id 0x41); sticks disabled.
enum PadMode { PAD_MODE_HYBRID = 0, PAD_MODE_ANALOG = 1, PAD_MODE_DIGITAL = 2 };
// Parse/format a pad mode. pad_mode_from_string accepts "hybrid"/"analog"/
// "digital" (case-insensitive) and returns `fallback` for anything else.
int pad_mode_from_string(const std::string& s, int fallback);
const char* pad_mode_to_string(int mode);
// [runtime] block — consumed by runtime/src/main.cpp. All fields optional;
// callers that need them check has_* flags or use the supplied defaults.
struct RuntimeConfig {
@@ -138,15 +155,18 @@ struct RuntimeConfig {
bool audio_spu_hq = false;
// ---- [controller] block — game-declared input defaults ----
// default_analog: present a DualShock/analog pad (id 0x73) by default for
// this game, so a stick-capable title (e.g. Tomba, which gives variable run
// speed through the analog stick) feels right out of the box without the
// user toggling DualShock in the launcher. Per-install settings.toml
// [controller] p1_analog/p2_analog still override. Default off (digital 0x41).
// `default_analog = true` sets both ports; `p1_analog`/`p2_analog` set one.
bool has_default_analog = false;
bool default_p1_analog = false;
bool default_p2_analog = false;
// default_mode: the pad input mode this game ships with (see PadMode):
// "hybrid" (default) auto-switches DualShock/digital from the player's
// input, "analog" pins DualShock (0x73), "digital" pins a digital pad
// (0x41). A stick-capable title (e.g. Tomba) ships "hybrid" so the stick
// gives variable run speed yet the D-pad keeps its classic digital feel,
// with no launcher toggling. Per-install settings.toml [controller]
// p1_mode/p2_mode still override. `default_mode` sets both ports;
// `p1_mode`/`p2_mode` set one. Legacy `default_analog`/`p1_analog`/
// `p2_analog` booleans are still accepted (true->analog, false->digital).
bool has_default_mode = false;
int default_p1_mode = PAD_MODE_HYBRID;
int default_p2_mode = PAD_MODE_HYBRID;
// deadzone: default analog-stick deadzone in raw SDL axis units (0..32767).
// Applied both to the stick->d-pad press threshold and the analog-axis centre
@@ -348,12 +368,15 @@ struct UserSettings {
// "none" — no pad in this port (port not connected)
// "keyboard" — driven by the keyboard map (input.ini)
// "<GUID>" — an SDL game-controller GUID (SDL_JoystickGetGUIDString)
// analog selects the emulated pad type: false = digital (0x41), true =
// DualShock/analog (0x73). Defaults: P1 keyboard/digital, P2 none/digital.
// p1_mode/p2_mode select the emulated pad behaviour (see PadMode):
// hybrid (default) / analog / digital. Defaults: P1 keyboard, P2 none.
bool has_p1_device = false; std::string p1_device = "keyboard";
bool has_p2_device = false; std::string p2_device = "none";
bool has_p1_analog = false; bool p1_analog = false;
bool has_p2_analog = false; bool p2_analog = false;
// Pad input mode per player (see PadMode): hybrid (default) / analog /
// digital. Persisted as p1_mode/p2_mode strings. Legacy p1_analog/p2_analog
// booleans are still read for back-compat (true->analog, false->digital).
bool has_p1_mode = false; int p1_mode = PAD_MODE_HYBRID;
bool has_p2_mode = false; int p2_mode = PAD_MODE_HYBRID;
// Analog-stick deadzone, raw SDL axis units (0..32767). The launcher edits
// this as 0-100% (raw = pct*32767/100), mirroring snesrecomp's GamepadDeadzone.
bool has_deadzone = false; int deadzone = 12000;
+24 -6
View File
@@ -101,6 +101,18 @@ button:hover { background-color: #232d3c; }
.knob { display: block; width: 16dp; height: 16dp; border-radius: 8dp; background-color: #e6e9ef; margin: 3dp 0dp 0dp 3dp; }
.switch.on .knob { margin-left: 23dp; }
/* Pad-mode 3-way segmented selector (Hybrid | Analog | D-Pad). */
.seg { display: flex; flex-direction: row; align-items: center; position: relative;
background-color: #161d28; border: 1dp #2b3543; border-radius: 7dp; }
.seg-opt { display: block; padding: 5dp 11dp; color: #8b949e; font-size: 12dp; border-radius: 6dp; }
.seg-opt:hover { color: #cdd5df; }
.seg-opt.on { background-color: #2563eb; color: #ffffff; }
/* Hybrid carries the explanatory tooltip, revealed on hover. */
.seg-tip { display: none; position: absolute; top: 34dp; right: 0dp; width: 250dp; z-index: 80;
background-color: #0d1219; border: 1dp #3b82f6; border-radius: 7dp;
padding: 10dp 12dp; color: #cdd5df; font-size: 11dp; line-height: 16dp; }
.seg-opt.hybrid:hover .seg-tip { display: block; }
.card-body { display: flex; flex-direction: row; align-items: center; }
.card-body.disabled { opacity: 0.4; }
/* Player card body is a fixed height so toggling the pad type never reflows. */
@@ -219,11 +231,14 @@ button:hover { background-color: #232d3c; }
<div class="card left">
<div class="card-head">
<span class="card-name">PLAYER 1</span>
<span class="enable-lbl">DualShock</span>
<span class="switch" data-class-on="p1_analog" data-event-click="toggle_ds_p1"><span class="knob"></span></span>
<div class="seg">
<span class="seg-opt hybrid" data-class-on="p1_mode == 0" data-event-click="set_mode_p1(0)">Hybrid<span class="seg-tip">Mirrors Tomba Special Edition: nudge the analog stick for smooth analog movement, or use the D-pad for classic digital control. It switches automatically based on what you touch.</span></span>
<span class="seg-opt" data-class-on="p1_mode == 1" data-event-click="set_mode_p1(1)">Analog</span>
<span class="seg-opt" data-class-on="p1_mode == 2" data-event-click="set_mode_p1(2)">D-Pad</span>
</div>
</div>
<div class="card-body player">
<div class="pad-art" data-class-analog="p1_analog" data-class-digital="p1_analog == false"></div>
<div class="pad-art" data-class-analog="p1_mode != 2" data-class-digital="p1_mode == 2"></div>
<div class="card-col">
<div class="field-lbl">Device</div>
<div class="dropdown-wrap">
@@ -240,11 +255,14 @@ button:hover { background-color: #232d3c; }
<div class="card right">
<div class="card-head">
<span class="card-name">PLAYER 2</span>
<span class="enable-lbl">DualShock</span>
<span class="switch" data-class-on="p2_analog" data-event-click="toggle_ds_p2"><span class="knob"></span></span>
<div class="seg">
<span class="seg-opt hybrid" data-class-on="p2_mode == 0" data-event-click="set_mode_p2(0)">Hybrid<span class="seg-tip">Mirrors Tomba Special Edition: nudge the analog stick for smooth analog movement, or use the D-pad for classic digital control. It switches automatically based on what you touch.</span></span>
<span class="seg-opt" data-class-on="p2_mode == 1" data-event-click="set_mode_p2(1)">Analog</span>
<span class="seg-opt" data-class-on="p2_mode == 2" data-event-click="set_mode_p2(2)">D-Pad</span>
</div>
</div>
<div class="card-body player">
<div class="pad-art" data-class-analog="p2_analog" data-class-digital="p2_analog == false"></div>
<div class="pad-art" data-class-analog="p2_mode != 2" data-class-digital="p2_mode == 2"></div>
<div class="card-col">
<div class="field-lbl">Device</div>
<div class="dropdown-wrap">
+27 -19
View File
@@ -133,8 +133,10 @@ struct LauncherModel {
// Keyboard / a plugged-in SDL controller) and a pad type (DualShock=analog).
int p1_dev_index = 1; // index into the shared device option list
int p2_dev_index = 0;
bool p1_analog = false; // DualShock (analog) vs digital pad
bool p2_analog = false;
// Pad input mode (PSXRecompV4::PadMode): 0=hybrid (default), 1=analog,
// 2=digital. Bound to the segmented 3-way selector in each player card.
int p1_mode = 0;
int p2_mode = 0;
int deadzone_pct = 37; // analog-stick deadzone 0-100% (raw = pct*32767/100)
Rml::String p1_dev_label = "Keyboard";
Rml::String p2_dev_label = "None";
@@ -316,14 +318,16 @@ void refresh_player(LauncherModel& m, int player, const std::vector<DeviceOption
Rml::String& status = player == 0 ? m.p1_status : m.p2_status;
Rml::String& dot = player == 0 ? m.p1_dot : m.p2_dot;
Rml::String& options= player == 0 ? m.p1_options : m.p2_options;
const bool analog = player == 0 ? m.p1_analog : m.p2_analog;
const int mode = player == 0 ? m.p1_mode : m.p2_mode;
if (idx < 0 || idx >= (int)opts.size()) idx = 0;
const DeviceOption& o = opts[idx];
label = o.label;
options = build_options_rml(player, opts);
const char* type = analog ? "DualShock (analog)" : "digital pad";
const char* type = mode == 1 ? "DualShock (analog)"
: mode == 2 ? "digital pad"
: "hybrid (auto analog/d-pad)";
if (o.kind == 0) { status = "No device — port empty"; dot = "off"; }
else if (o.kind == 1) { status = Rml::String("Keyboard \xE2\x80\x94 ") + type; dot = ""; }
else { status = o.label + Rml::String(" \xE2\x80\x94 ") + type; dot = ""; }
@@ -615,8 +619,8 @@ Result run(SDL_Window* window, void* gl_context,
// ---- Seed the controller slots: enumerate devices, resolve selections ----
std::vector<DeviceOption> dev_opts = enumerate_devices();
m.p1_analog = io.has_p1_analog ? io.p1_analog : false;
m.p2_analog = io.has_p2_analog ? io.p2_analog : false;
m.p1_mode = io.has_p1_mode ? io.p1_mode : PSXRecompV4::PAD_MODE_HYBRID;
m.p2_mode = io.has_p2_mode ? io.p2_mode : PSXRecompV4::PAD_MODE_HYBRID;
m.deadzone_pct = io.has_deadzone ? (io.deadzone * 100 / 32767) : 37;
if (io.has_p1_device) {
m.p1_dev_index = find_or_add_device_index(dev_opts, io.p1_device);
@@ -660,8 +664,8 @@ Result run(SDL_Window* window, void* gl_context,
c.Bind("verdict_detail", &m.verdict_detail);
c.Bind("verdict_state", &m.verdict_state);
c.Bind("view", &m.view);
c.Bind("p1_analog", &m.p1_analog);
c.Bind("p2_analog", &m.p2_analog);
c.Bind("p1_mode", &m.p1_mode);
c.Bind("p2_mode", &m.p2_mode);
c.Bind("deadzone_pct", &m.deadzone_pct);
c.Bind("p1_dev_label", &m.p1_dev_label);
c.Bind("p2_dev_label", &m.p2_dev_label);
@@ -787,10 +791,10 @@ Result run(SDL_Window* window, void* gl_context,
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.view = "dashboard"; handle.DirtyVariable("view");
});
// ---- controller: device dropdown + DualShock toggle ----
// ---- controller: device dropdown + pad-mode segmented selector ----
auto dirty_player = [handle](int player) mutable {
const char* v0[] = {"p1_dev_label","p1_status","p1_dot","p1_options","p1_analog"};
const char* v1[] = {"p2_dev_label","p2_status","p2_dot","p2_options","p2_analog"};
const char* v0[] = {"p1_dev_label","p1_status","p1_dot","p1_options","p1_mode"};
const char* v1[] = {"p2_dev_label","p2_status","p2_dot","p2_options","p2_mode"};
for (const char* v : (player == 0 ? v0 : v1)) handle.DirtyVariable(v);
};
// dev_opts is captured by value: the device list is fixed for the launcher
@@ -817,13 +821,17 @@ Result run(SDL_Window* window, void* gl_context,
dirty_player(player);
handle.DirtyVariable("dd_open");
});
c.BindEventCallback("toggle_ds_p1",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.p1_analog = !m.p1_analog; refresh_player(m, 0, dev_opts); dirty_player(0);
// Pad-mode segmented selector: each segment passes its mode (0=hybrid,
// 1=analog, 2=digital) so any mode is one click away.
c.BindEventCallback("set_mode_p1",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList& args) mutable {
if (args.empty()) return;
m.p1_mode = (int)args[0].Get<int>(); refresh_player(m, 0, dev_opts); dirty_player(0);
});
c.BindEventCallback("toggle_ds_p2",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.p2_analog = !m.p2_analog; refresh_player(m, 1, dev_opts); dirty_player(1);
c.BindEventCallback("set_mode_p2",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList& args) mutable {
if (args.empty()) return;
m.p2_mode = (int)args[0].Get<int>(); refresh_player(m, 1, dev_opts); dirty_player(1);
});
/* Analog-stick deadzone, stepped 0..50% (wraps). Applies to both the
* stick->d-pad threshold and the analog centre dead-band. */
@@ -952,8 +960,8 @@ Result run(SDL_Window* window, void* gl_context,
const int i2 = (m.p2_dev_index >= 0 && m.p2_dev_index < (int)dev_opts.size()) ? m.p2_dev_index : 0;
io.p1_device = device_string(dev_opts[i1]); io.has_p1_device = true;
io.p2_device = device_string(dev_opts[i2]); io.has_p2_device = true;
io.p1_analog = m.p1_analog; io.has_p1_analog = true;
io.p2_analog = m.p2_analog; io.has_p2_analog = true;
io.p1_mode = m.p1_mode; io.has_p1_mode = true;
io.p2_mode = m.p2_mode; io.has_p2_mode = true;
io.deadzone = m.deadzone_pct * 32767 / 100; io.has_deadzone = true;
}
+97 -35
View File
@@ -105,7 +105,12 @@ static SDL_Texture* sdl_texture;
struct PlayerInput {
int kind = 0; /* 0=none, 1=keyboard, 2=controller */
char guid[40] = {0}; /* SDL joystick GUID string when kind==controller */
bool analog = false; /* DualShock (analog) vs digital pad */
/* Pad input mode (PSXRecompV4::PadMode): 0=hybrid (default), 1=analog,
* 2=digital. hybrid_analog is the per-frame auto-switch latch used only in
* hybrid mode: true => currently presenting DualShock (stick was the last
* input), false => currently presenting a digital pad (D-pad was last). */
int mode = PSXRecompV4::PAD_MODE_HYBRID;
bool hybrid_analog = false;
SDL_GameController* handle = nullptr;
SDL_JoystickID instance = -1;
};
@@ -963,6 +968,13 @@ static void open_player(PlayerInput& p, const PlayerInput& other) {
}
}
/* The pad type a mode reports before any input has been sampled (boot /
* hotplug). analog pins DualShock; digital and hybrid both start as a digital
* pad (hybrid only flips to DualShock once the player nudges the stick). */
static int pad_mode_boot_analog(int mode) {
return mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0;
}
/* Open/close SDL handles so they match g_players, and (re)assert each slot's
* PSX connection + pad type. Safe to call repeatedly (hotplug, boot). */
static void refresh_player_devices(void) {
@@ -971,14 +983,15 @@ static void refresh_player_devices(void) {
if (p.kind != 2) close_player(p); /* keyboard/none: no handle */
else open_player(p, g_players[s ^ 1]);
sio_set_pad_connected(s, p.kind != 0 ? 1 : 0);
sio_set_pad_analog(s, p.analog ? 1 : 0, 0x80, 0x80, 0x80, 0x80);
sio_set_pad_analog(s, pad_mode_boot_analog(p.mode), 0x80, 0x80, 0x80, 0x80);
}
}
/* Parse a [controller] device string into a player slot:
* "none" -> no pad; "keyboard" -> keyboard map; otherwise an SDL GUID. */
static void set_player_device(PlayerInput& p, const std::string& dev, bool analog) {
p.analog = analog;
static void set_player_device(PlayerInput& p, const std::string& dev, int mode) {
p.mode = mode;
p.hybrid_analog = false; /* hybrid starts digital until the stick moves */
p.guid[0] = '\0';
std::string d = lower_copy(trim_copy(dev));
if (d.empty() || d == "none") { p.kind = 0; }
@@ -1078,15 +1091,18 @@ static uint16_t pad_buttons_for(const PlayerInput& p) {
/* Analog stick bytes (lx,ly,rx,ry) for a player; centred if no live source.
*
* The host left stick maps to the LEFT analog axes (variable). The physical
* D-pad (and, for a keyboard player, the arrow keys) is ALSO folded onto the
* left axes at full deflection, so an analog-mode game whose movement reads only
* the stick magnitude (e.g. Tomba) still responds to the D-pad/keyboard. This is
* the faithful "seamless" behaviour the Special Edition exposes: the stick gives
* variable speed, the D-pad gives full speed, and both work at once. Only the
* PHYSICAL D-pad is folded in (not the stick->d-pad button mapping), so the
* stick keeps its true variable magnitude rather than snapping to full. */
static void pad_sticks_for(const PlayerInput& p, uint8_t out[4]) {
* The host left stick maps to the LEFT analog axes (variable). When fold_dpad
* is set, the physical D-pad (and, for a keyboard player, the arrow keys) is
* ALSO folded onto the left axes at full deflection, so an analog-mode game
* whose movement reads only the stick magnitude (e.g. Tomba) still responds to
* the D-pad/keyboard — the faithful "seamless" behaviour: the stick gives
* variable speed, the D-pad gives full speed, both at once. Only the PHYSICAL
* D-pad is folded in (not the stick->d-pad button mapping), so the stick keeps
* its true variable magnitude. fold_dpad is false in HYBRID mode (there the
* D-pad instead flips the pad to digital, so the game runs its own d-pad path)
* and true in pinned-ANALOG mode. The keyboard branch always folds the arrows
* — they are that player's only stick source. */
static void pad_sticks_for(const PlayerInput& p, uint8_t out[4], bool fold_dpad) {
out[0] = out[1] = out[2] = out[3] = 0x80;
if (p.kind == 1) {
const Uint8* keys = SDL_GetKeyboardState(NULL);
@@ -1101,13 +1117,44 @@ static void pad_sticks_for(const PlayerInput& p, uint8_t out[4]) {
out[1] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY));
out[2] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX));
out[3] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY));
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) out[0] = 0x00;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) out[0] = 0xFF;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP)) out[1] = 0x00;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) out[1] = 0xFF;
if (fold_dpad) {
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) out[0] = 0x00;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) out[0] = 0xFF;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP)) out[1] = 0x00;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) out[1] = 0xFF;
}
}
}
/* HYBRID-mode auto-switch detectors (mirror the DualShock analog LED toggling).
* hybrid_stick_active: the LEFT stick is deflected past the deadzone — the
* player has reached for analog. hybrid_dpad_active: any D-pad direction (or,
* for the keyboard, an arrow key) is held — the player wants classic digital.
* The keyboard has no analog stick, so a keyboard player stays digital. */
static bool hybrid_stick_active(const PlayerInput& p) {
if (p.kind != 2 || !p.handle) return false;
const int dz = controller_deadzone;
int lx = SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX);
int ly = SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY);
return lx > dz || lx < -dz || ly > dz || ly < -dz;
}
static bool hybrid_dpad_active(const PlayerInput& p, bool kb_always) {
if (p.kind == 2 && p.handle) {
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT) ||
SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) ||
SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP) ||
SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN))
return true;
}
if (p.kind == 1 || kb_always) {
const Uint8* keys = SDL_GetKeyboardState(NULL);
if (keys[SDL_SCANCODE_LEFT] || keys[SDL_SCANCODE_RIGHT] ||
keys[SDL_SCANCODE_UP] || keys[SDL_SCANCODE_DOWN])
return true;
}
return false;
}
/* PSX native vblank cadence: NTSC ≈ 59.94 Hz. Wall-clock target keeps
* audio sample generation (735 samples/vblank * 60 = 44100/sec) matched
* to the SDL audio device drain rate, eliminating queue overflow drops
@@ -1181,14 +1228,29 @@ static void sdl_vblank_present(void) {
sio_set_pad_state_slot(0, (uint16_t)override);
} else {
for (int s = 0; s < 2; s++) {
const PlayerInput& p = g_players[s];
PlayerInput& p = g_players[s];
if (p.kind == 0) continue; /* no device in this port */
sio_set_pad_state_slot(s, pad_buttons_for(p));
if (p.analog) {
uint8_t st[4];
pad_sticks_for(p, st);
sio_set_pad_analog(s, 1, st[0], st[1], st[2], st[3]);
/* Resolve the pad type this frame from the player's mode. HYBRID
* auto-switches DualShock<->digital from the most-recent input
* (stick -> analog, D-pad -> digital), mirroring the Special
* Edition's analog-LED auto-detect, so the game runs its own analog
* or digital input path exactly as on hardware. */
int eff_analog;
uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 };
if (p.mode == PSXRecompV4::PAD_MODE_DIGITAL) {
eff_analog = 0;
} else if (p.mode == PSXRecompV4::PAD_MODE_ANALOG) {
eff_analog = 1;
pad_sticks_for(p, st, /*fold_dpad=*/true);
} else { /* HYBRID */
if (hybrid_stick_active(p)) p.hybrid_analog = true;
else if (hybrid_dpad_active(p, false)) p.hybrid_analog = false;
eff_analog = p.hybrid_analog ? 1 : 0;
if (eff_analog) pad_sticks_for(p, st, /*fold_dpad=*/false);
}
sio_set_pad_analog(s, eff_analog, st[0], st[1], st[2], st[3]);
}
}
@@ -1550,8 +1612,8 @@ int main(int argc, char** argv) {
/* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */
std::string p1_device = "keyboard";
std::string p2_device = "none";
bool p1_analog = false;
bool p2_analog = false;
int p1_mode = PSXRecompV4::PAD_MODE_HYBRID;
int p2_mode = PSXRecompV4::PAD_MODE_HYBRID;
int resolved_deadzone = -1; /* <0 => keep input.ini/runtime default (12000) */
std::filesystem::path resolved_disc;
std::string window_title = PSX_WINDOW_TITLE;
@@ -1603,9 +1665,9 @@ 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). */
if (gc.runtime.has_default_analog) {
p1_analog = gc.runtime.default_p1_analog;
p2_analog = gc.runtime.default_p2_analog;
if (gc.runtime.has_default_mode) {
p1_mode = gc.runtime.default_p1_mode;
p2_mode = gc.runtime.default_p2_mode;
}
if (gc.runtime.has_deadzone) resolved_deadzone = gc.runtime.deadzone;
{ const char *e = std::getenv("PSX_GL_FORCE_CPU_PRESENT");
@@ -1699,8 +1761,8 @@ int main(int argc, char** argv) {
if (us.has_memcard2_enabled) memcard2_enabled = us.memcard2_enabled;
if (us.has_p1_device) p1_device = us.p1_device;
if (us.has_p2_device) p2_device = us.p2_device;
if (us.has_p1_analog) p1_analog = us.p1_analog;
if (us.has_p2_analog) p2_analog = us.p2_analog;
if (us.has_p1_mode) p1_mode = us.p1_mode;
if (us.has_p2_mode) p2_mode = us.p2_mode;
if (us.has_deadzone) resolved_deadzone = us.deadzone;
}
@@ -1777,8 +1839,8 @@ int main(int argc, char** argv) {
if (!memcard2_path.empty()) { seed.memcard2_path = memcard2_path; seed.has_memcard2_path = true; }
seed.p1_device = p1_device; seed.has_p1_device = true;
seed.p2_device = p2_device; seed.has_p2_device = true;
seed.p1_analog = p1_analog; seed.has_p1_analog = true;
seed.p2_analog = p2_analog; seed.has_p2_analog = true;
seed.p1_mode = p1_mode; seed.has_p1_mode = true;
seed.p2_mode = p2_mode; seed.has_p2_mode = true;
seed.deadzone = resolved_deadzone >= 0 ? resolved_deadzone : 12000;
seed.has_deadzone = true;
seed.window_width = g_video_win_w; seed.has_window_width = true;
@@ -1845,7 +1907,7 @@ int main(int argc, char** argv) {
if (seed.has_memcard1_path) memcard1_path = seed.memcard1_path;
if (seed.has_memcard2_path) memcard2_path = seed.memcard2_path;
p1_device = seed.p1_device; p2_device = seed.p2_device;
p1_analog = seed.p1_analog; p2_analog = seed.p2_analog;
p1_mode = seed.p1_mode; p2_mode = seed.p2_mode;
if (seed.has_deadzone) resolved_deadzone = seed.deadzone;
g_video_win_w = seed.window_width;
/* Persist the user's choices next to the exe. */
@@ -1929,11 +1991,11 @@ int main(int argc, char** argv) {
* SDL controller handles are opened later (after SDL_Init); here we only
* set the PSX-visible connection + pad type so the BIOS sees the right
* ports during early boot. */
set_player_device(g_players[0], p1_device, p1_analog);
set_player_device(g_players[1], p2_device, p2_analog);
set_player_device(g_players[0], p1_device, p1_mode);
set_player_device(g_players[1], p2_device, p2_mode);
for (int s = 0; s < 2; s++) {
sio_set_pad_connected(s, g_players[s].kind != 0 ? 1 : 0);
sio_set_pad_analog(s, g_players[s].analog ? 1 : 0, 0x80, 0x80, 0x80, 0x80);
sio_set_pad_analog(s, pad_mode_boot_analog(g_players[s].mode), 0x80, 0x80, 0x80, 0x80);
}
/* SPU float-shadow gate must be set before spu_init() (which runs
* spu_shadow_reset()). Default OFF; PSX_AUDIO_SHADOW env overrides. */