From 4cdccf58c19d2ae754615022b9fa5e676158fe15 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 7 Aug 2026 10:19:12 -0400 Subject: [PATCH] multitap enhancements --- docs/NETPLAY.md | 23 ++++- recompiler/src/config_loader.cpp | 121 ++++++++++++++++++++++- recompiler/src/config_loader.h | 19 ++++ runtime/include/psx_lobby_client.h | 2 + runtime/include/sio.h | 10 +- runtime/src/main.cpp | 150 ++++++++++++++++++++++++++--- runtime/src/psx_lobby_client.c | 4 +- runtime/src/psx_netplay.c | 13 ++- runtime/src/sio.c | 25 ++++- 9 files changed, 334 insertions(+), 33 deletions(-) diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index cd3c5c66..aa5b7f80 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -103,9 +103,26 @@ Guests rearrange among seats **1..N−1**. | Library / lobby / UI ceiling | **8** seats (`RNET_MAX_SLOTS`, dual SCPH-1070) | | Per-title cap | `game.toml` `players` / `PSX_MAX_PLAYERS` (e.g. MotK=2, Bomberman Party=5) | -Rollback and delay-sync both carry multitap pad bytes. Offline and netplay -share the same multitap arming rules (game-started gate, `multitap_port` in -`game.toml`). Empty tap seats are fine — not every slot needs a device. +**Offline:** recomp-ui exposes a Multitap checkbox on PSX titles with +`num_players > 4`. Off hides controller seats beyond Player 4 and caps +`g_offline_pad_count` at 4 (`settings.toml` `[controller] multitap`, +default on). Multitap still arms at game-start when three or more offline +seats are live (`multitap_port` from `game.toml`). + +**Multitap analog (hack):** tap seats are plain digital by default. Opt in +with `game.toml` / `settings.toml` `[controller] multitap_analog = true` +(or the Controllers / Lobby Settings checkbox). When on, tap seats may +report DualShock (`0x73` + sticks) in multitap bulk status. Hosts publish +`match_caps.multitap_analog` so every peer applies the same setting at +launch. Not reliable across titles — leave off unless the game needs it. + +**Netplay (psxrecomp only):** lobbies with more than **2** seats always +force SCPH-1070 multitap on (`force_session_pads_connected` / +session start when `slot_count >= 3`). The offline Multitap toggle does +not opt out of that. Empty tap seats are fine — not every slot needs a +device. + +Rollback and delay-sync both carry multitap pad bytes. --- diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 49e9db89..57c1723d 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -6,9 +6,11 @@ #include #include #include +#include #include #include #include +#include #include "bios_rom_alias.h" #include "fmt/format.h" @@ -696,6 +698,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.multitap_port = static_cast(n); rt.has_multitap_port = true; } + if (ct.contains("multitap_analog")) { + rt.multitap_analog = toml::find(ct, "multitap_analog"); + rt.has_multitap_analog = true; + } // Prefer string key; accept legacy bool alias. if (ct.contains("legacy_pad_config")) { rt.legacy_pad_config = toml::find(ct, "legacy_pad_config"); @@ -2314,6 +2320,14 @@ UserSettings load_user_settings(const fs::path& path) { } }); } + if (ct.contains("multitap")) try_get([&]{ + s.multitap_enabled = toml::find(ct, "multitap"); + s.has_multitap_enabled = true; + }); + if (ct.contains("multitap_analog")) try_get([&]{ + s.multitap_analog = toml::find(ct, "multitap_analog"); + s.has_multitap_analog = true; + }); if (ct.contains("deadzone")) try_get([&]{ const auto n = toml::find(ct, "deadzone"); if (n >= 0 && n <= 32767) { @@ -2429,7 +2443,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { } { - bool any_ctrl = s.has_deadzone; + bool any_ctrl = s.has_deadzone || s.has_multitap_enabled || + s.has_multitap_analog; 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; @@ -2452,6 +2467,12 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_p_deadzone[i]) f << kDzKeys[i] << " = " << s.p_deadzone[i] << "\n"; } + if (s.has_multitap_enabled) + f << "multitap = " << (s.multitap_enabled ? "true" : "false") + << "\n"; + if (s.has_multitap_analog) + f << "multitap_analog = " + << (s.multitap_analog ? "true" : "false") << "\n"; /* Keep a global deadzone= for older readers (mirrors P1). */ if (s.has_deadzone || s.has_p_deadzone[0]) f << "deadzone = " @@ -2478,4 +2499,102 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { return f.good(); } +bool upsert_game_toml_controller_bool(const std::filesystem::path& path, + const char* key, bool value) +{ + if (!key || !key[0]) return false; + std::string text; + { + std::ifstream in(path); + if (in) { + std::ostringstream ss; + ss << in.rdbuf(); + text = ss.str(); + } + } + const std::string val = value ? "true" : "false"; + const std::string assign = std::string(key) + " = " + val; + + auto is_section = [](const std::string& line) { + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) ++i; + return i < line.size() && line[i] == '['; + }; + auto section_name = [](const std::string& line) -> std::string { + size_t a = line.find('['); + size_t b = line.find(']'); + if (a == std::string::npos || b == std::string::npos || b <= a + 1) + return {}; + return line.substr(a + 1, b - a - 1); + }; + auto key_prefix = [&](const std::string& line) { + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) ++i; + if (i < line.size() && line[i] == '#') return false; + const std::string rest = line.substr(i); + return rest.rfind(std::string(key) + " =", 0) == 0 || + rest.rfind(std::string(key) + "=", 0) == 0; + }; + + std::istringstream ls(text); + std::vector lines; + std::string line; + while (std::getline(ls, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(line); + } + + int ctrl_start = -1, ctrl_end = -1; + for (int i = 0; i < (int)lines.size(); ++i) { + if (is_section(lines[i]) && section_name(lines[i]) == "controller") { + ctrl_start = i; + ctrl_end = (int)lines.size(); + for (int j = i + 1; j < (int)lines.size(); ++j) { + if (is_section(lines[j])) { + ctrl_end = j; + break; + } + } + break; + } + } + + if (ctrl_start < 0) { + if (!text.empty() && text.back() != '\n') text.push_back('\n'); + text += "\n[controller]\n"; + text += assign; + text += "\n"; + } else { + int replace_at = -1; + for (int i = ctrl_start + 1; i < ctrl_end; ++i) { + if (key_prefix(lines[i])) { + replace_at = i; + break; + } + } + if (replace_at >= 0) { + lines[replace_at] = assign; + } else { + int insert_at = ctrl_end; + while (insert_at > ctrl_start + 1 && + lines[insert_at - 1].find_first_not_of(" \t") == + std::string::npos) + --insert_at; + lines.insert(lines.begin() + insert_at, assign); + } + std::ostringstream out; + for (size_t i = 0; i < lines.size(); ++i) { + out << lines[i]; + if (i + 1 < lines.size() || (!text.empty() && text.back() == '\n')) + out << '\n'; + } + text = out.str(); + } + + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f << text; + return f.good(); +} + } // namespace PSXRecompV4 diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 52ce7090..22824442 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -511,6 +511,12 @@ struct RuntimeConfig { bool has_multitap_port = false; int multitap_port = 1; + // multitap_analog: opt-in DualShock-on-tap hack (default false). When true, + // multitap bulk seats may report 0x73 + stick bytes; when false (faithful), + // tap seats stay plain digital. Overridable by settings.toml / match_caps. + bool has_multitap_analog = false; + bool multitap_analog = false; + // legacy_pad_config: per-game pad-protocol compatibility opt-in. false (default) // = the modern DualShock config state machine (proper 0x43 enter/exit, config id // 0xF3 only while in config) — required by MMX6 and the correct default for every @@ -1115,6 +1121,13 @@ struct UserSettings { // Legacy global deadzone (settings.toml `deadzone=`). Applied to slots // that do not have an explicit pN_deadzone. Default 10%. bool has_deadzone = false; int deadzone = 3277; + // SCPH-1070 multitap for offline seats beyond Player 4 (settings.toml + // [controller] multitap). Default ON when unset. Netplay lobbies with + // more than 2 seats always arm multitap in the runtime regardless. + bool has_multitap_enabled = false; bool multitap_enabled = true; + // DualShock-on-tap hack (settings.toml [controller] multitap_analog). + // Default off when unset; game.toml [controller] multitap_analog seeds it. + bool has_multitap_analog = false; bool multitap_analog = false; // Localization: the launcher's chosen language code (feeds RuntimeConfig // .language / g_lang). "off"/"jp"/"" = untranslated native game. Persisted to // settings.toml [localization].language. @@ -1167,6 +1180,12 @@ UserSettings load_user_settings(const std::filesystem::path& path); // Write settings.toml deterministically. Returns false on I/O failure. bool save_user_settings(const std::filesystem::path& path, const UserSettings& s); +// Surgical upsert of `key = true|false` under [controller] in game.toml. +// Preserves comments and unrelated keys. Creates [controller] if missing. +// Used to persist launcher multitap_analog into the title game config. +bool upsert_game_toml_controller_bool(const std::filesystem::path& path, + const char* key, bool value); + // Locate the project root by walking upward from `config_path` until a // directory containing `.gitignore`, `.git`, or `CMakeLists.txt` is found. // Throws std::runtime_error if not found within 8 levels. diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 82697c21..27b5c603 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -62,6 +62,8 @@ typedef struct PsxLobbyMatchCaps { int force_input_relay; /* 0/1 — server input relay (vs P2P) */ int force_turn; /* 0/1 — ICE relay-only (Force TURN for UDP) */ int rollback; /* 0/1 — invent/rollback netplay (default on) */ + /* DualShock-on-multitap-tap hack (0/1). Host-authoritative for the match. */ + int multitap_analog; char language[PSX_LOBBY_LANG_LEN]; } PsxLobbyMatchCaps; diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 0d7f8411..d32f87cf 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -84,9 +84,15 @@ void sio_set_legacy_cfg(int enabled); int sio_get_legacy_cfg(void); /* 1 when multitap is armed and `logical_slot` is a tap pad (not the lone * pad on the opposite console port). SCPH-1070 taps are treated as plain - * digital controllers (0x41) — DualShock/analog on a tap is not reliable - * across titles, so host input and SIO type requests are forced digital. */ + * digital controllers (0x41) by default — DualShock/analog on a tap is not + * reliable across titles. Opt in with sio_set_multitap_analog(1) (hack). */ int sio_pad_on_multitap(int logical_slot); +/* Opt-in DualShock-on-tap hack. Off by default. When on, tap seats may + * report 0x73 + stick bytes in multitap bulk status (and host/netplay + * sampling stops forcing digital). game.toml [controller] multitap_analog / + * settings.toml / lobby match_caps may enable this. */ +void sio_set_multitap_analog(int enabled); +int sio_get_multitap_analog(void); /* Update pad button state. Buttons use PS1 convention: 0=pressed, 1=released. Bit layout: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0b1ae4b8..3165da7f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -329,6 +329,17 @@ struct PlayerInput { static PlayerInput g_players[PSX_MAX_PLAYERS]; /* Offline SIO sample loop bound (from game.toml players; clamped). */ static int g_offline_pad_count = 2; +/* Offline seat ceiling from game.toml players, optionally capped at 4 when + * the launcher Multitap toggle is off (seats 5+). Netplay ignores this and + * arms multitap whenever session slot_count > 2. */ +static void apply_offline_pad_count(int game_players, bool multitap_enabled) +{ + int n = game_players > 0 ? game_players : 1; + if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; + if (game_players > 4 && !multitap_enabled && n > 4) + n = 4; + g_offline_pad_count = n; +} /* ARGB8888 staging buffer. Sized for the active internal resolution: * 640*scale x 512*scale. Allocated once the supersampling scale is known * (sized for the native 640x512 when supersampling is off). */ @@ -3416,10 +3427,10 @@ static int effective_player_mode(const PlayerInput& p) { return p.mode; } -/* Pad mode for the SIO seat this sample will drive. Multitap taps are always - * plain digital (0x41) — DualShock on a SCPH-1070 tap is not reliable. */ +/* Pad mode for the SIO seat this sample will drive. Multitap taps stay digital + * unless the DualShock-on-tap hack (sio_get_multitap_analog) is armed. */ static int effective_player_mode_for_sio(const PlayerInput& p, int sio_slot) { - if (sio_pad_on_multitap(sio_slot)) + if (sio_pad_on_multitap(sio_slot) && !sio_get_multitap_analog()) return (int)PSXRecompV4::PAD_MODE_DIGITAL; return effective_player_mode(p); } @@ -3869,10 +3880,10 @@ static int capture_pad_slot(int s, PsxNetPad* out) { * analog axes below. An assigned device keeps its configured mode (a * launcher-selected analog DualShock stays analog, so its input path / SIO * handshake cadence is preserved exactly). Keyboard is always digital. - * Multitap taps are forced digital. A P1 with no assigned device but - * dev-any-input on presents as HYBRID. */ + * Multitap taps are forced digital unless multitap_analog hack is on. + * A P1 with no assigned device but dev-any-input on presents as HYBRID. */ int mode; - if (sio_pad_on_multitap(s)) + if (sio_pad_on_multitap(s) && !sio_get_multitap_analog()) mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; else if (p.kind != 0) mode = effective_player_mode(p); else if (dev_here) mode = (int)PSXRecompV4::PAD_MODE_HYBRID; @@ -3978,7 +3989,7 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out, int present_sio_slo } static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { - if (sio_pad_on_multitap(s)) + if (sio_pad_on_multitap(s) && !sio_get_multitap_analog()) sio_set_pad_config_capable(s, 0); sio_set_pad_state_slot(s, pad.buttons); sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry); @@ -4974,16 +4985,19 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_offline_pad_count >= 3 && fntrace_is_game_started() && !sio_get_multitap()) { sio_set_multitap(1); - /* Tap seats drop to plain digital as soon as the tap is live. */ + /* Tap seats drop to plain digital unless multitap_analog is on. */ for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { if (!sio_pad_on_multitap(s)) continue; + if (sio_get_multitap_analog()) continue; sio_set_pad_config_capable(s, 0); sio_set_pad_analog(s, 0, 0x80, 0x80, 0x80, 0x80); } std::fprintf(stdout, "psxrecomp: multitap armed (console Port %d; " - "tap pads forced digital)\n", - sio_get_multitap_port() + 1); + "tap pads %s)\n", + sio_get_multitap_port() + 1, + sio_get_multitap_analog() ? "analog hack on" + : "forced digital"); } /* Solo resim self-check replay republishes the recorded rows itself — * live sampling must not touch SIO during the replay window. */ @@ -5958,6 +5972,7 @@ namespace { int g_lnch_force_turn = 0; /* Lobby default on; host “Disable Rollback” clears this → delay_sync. */ int g_lnch_rollback = 1; + int g_lnch_multitap_analog = 0; int g_lnch_host_max_slots = 2; /* Delay-sync READY/START waits for every seat in slot_count. Use seated @@ -7090,6 +7105,8 @@ namespace { caps.force_input_relay = g_lnch_force_input_relay != 0; caps.force_turn = g_lnch_force_turn != 0; caps.rollback = g_lnch_rollback != 0; + caps.multitap_analog = g_lnch_multitap_analog != 0; + if (s) caps.multitap_analog = s->multitap_analog != 0; return caps; } @@ -7109,6 +7126,8 @@ namespace { caps.force_input_relay = g_lnch_force_input_relay != 0; caps.force_turn = g_lnch_force_turn != 0; caps.rollback = g_lnch_rollback != 0; + caps.multitap_analog = g_lnch_multitap_analog != 0; + if (settings) caps.multitap_analog = settings->multitap_analog != 0; (void)psx_lobby_set_match_caps(&caps); } @@ -7174,6 +7193,19 @@ namespace { ae_np_push_match_caps(nullptr); return 0; } + int ae_np_multitap_analog_get(void*) { + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) + return caps->multitap_analog ? 1 : 0; + } + return g_lnch_multitap_analog; + } + int ae_np_multitap_analog_set(void*, int enable) { + g_lnch_multitap_analog = enable ? 1 : 0; + ae_np_push_match_caps(nullptr); + return 0; + } int ae_np_input_prediction_get(void*) { if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); @@ -8473,6 +8505,8 @@ namespace { ae_np_rollback_set, ae_np_input_prediction_get, ae_np_input_prediction_set, + ae_np_multitap_analog_get, + ae_np_multitap_analog_set, }; } // namespace #endif @@ -8637,6 +8671,8 @@ int main(int argc, char** argv) { std::filesystem::path memcard2_path; /* explicit slot-2 .mcd (empty => dir/card2.mcd) */ bool memcard1_enabled = true; bool memcard2_enabled = true; + bool multitap_enabled = true; /* [controller] multitap; seats 5+ offline */ + bool multitap_analog = false; /* DualShock-on-tap hack; game.toml/settings */ /* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */ /* Dev builds default Player 1 to the first connected controller ("auto"). * PSX_DEV_INPUT=1 additionally merges every other controller and the @@ -8717,9 +8753,7 @@ int main(int argc, char** argv) { game_id = gc.id; game_region = gc.region; game_players = gc.players; - g_offline_pad_count = game_players > 0 ? game_players : 1; - if (g_offline_pad_count > PSX_MAX_PLAYERS) - g_offline_pad_count = PSX_MAX_PLAYERS; + apply_offline_pad_count(game_players, multitap_enabled); game_has_disc_crc = gc.has_disc_crc; game_disc_crc = gc.disc_crc; g_netplay_disc_expect.require_cue = gc.netplay_require_cue; @@ -8919,6 +8953,17 @@ int main(int argc, char** argv) { "psxrecomp: multitap on console Port %d\n", gc.runtime.multitap_port); } + if (gc.runtime.has_multitap_analog) { + multitap_analog = gc.runtime.multitap_analog; + sio_set_multitap_analog(multitap_analog ? 1 : 0); +#if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) + g_lnch_multitap_analog = multitap_analog ? 1 : 0; +#endif + if (multitap_analog) + std::fprintf(stdout, + "psxrecomp: multitap_analog hack ON " + "(DualShock on tap seats)\n"); + } /* LEGACY per-game pad-config opt-in (default modern). Only Tomba sets * it, so its launcher Hybrid mode's analog<->digital flip doesn't make * libpad manufacture a 1-frame "pad unplugged". sio_init() does not @@ -9082,6 +9127,14 @@ int main(int argc, char** argv) { if (us.has_memcard2_path) memcard2_path = us.memcard2_path; 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_multitap_analog) { + multitap_analog = us.multitap_analog; + sio_set_multitap_analog(multitap_analog ? 1 : 0); +#if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) + g_lnch_multitap_analog = multitap_analog ? 1 : 0; +#endif + } if (us.has_language) resolved_language = us.language; { const int n = std::min(PSX_MAX_PLAYERS, @@ -9093,6 +9146,7 @@ int main(int argc, char** argv) { } if (us.has_deadzone) resolved_deadzone = us.deadzone; } + apply_offline_pad_count(game_players, multitap_enabled); if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; if (us.has_vsync) g_video_vsync = us.vsync; if (us.has_frame_interpolation) @@ -9489,6 +9543,8 @@ int main(int argc, char** argv) { seed.memcard_dir = memcard_dir; seed.has_memcard_dir = true; seed.memcard1_enabled = memcard1_enabled; seed.has_memcard1_enabled = true; seed.memcard2_enabled = memcard2_enabled; seed.has_memcard2_enabled = true; + seed.multitap_enabled = multitap_enabled; seed.has_multitap_enabled = true; + seed.multitap_analog = multitap_analog; seed.has_multitap_analog = true; if (!memcard1_path.empty()) { seed.memcard1_path = memcard1_path; seed.has_memcard1_path = true; } if (!memcard2_path.empty()) { seed.memcard2_path = memcard2_path; seed.has_memcard2_path = true; } seed.language = resolved_language; seed.has_language = true; @@ -9613,6 +9669,13 @@ int main(int argc, char** argv) { } ls.memcard_enabled[0] = seed.memcard1_enabled ? 1 : 0; ls.memcard_enabled[1] = seed.memcard2_enabled ? 1 : 0; +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) + ls.multitap_enabled = seed.multitap_enabled ? 1 : 0; +#endif +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) + ls.multitap_analog = seed.multitap_analog ? 1 : 0; + g_lnch_multitap_analog = seed.multitap_analog ? 1 : 0; +#endif /* Region badge: game.toml [game] region wins verbatim; otherwise derive * from the game_id serial prefix (SCUS/SLUS/LSP -> USA, SCES/SLES -> @@ -9749,9 +9812,7 @@ int main(int argc, char** argv) { #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; g_lnch_game_players = game_players; - g_offline_pad_count = game_players > 0 ? game_players : 1; - if (g_offline_pad_count > PSX_MAX_PLAYERS) - g_offline_pad_count = PSX_MAX_PLAYERS; + apply_offline_pad_count(game_players, multitap_enabled); psx_lobby_set_max_slots(game_players); gi.netplay_supported = (game_players >= 2 && game_players <= PSX_MAX_PLAYERS) ? 1 : 0; @@ -9867,6 +9928,22 @@ int main(int argc, char** argv) { /* Memory-card slots: enable flags + any Browse/New paths. */ seed.memcard1_enabled = ls.memcard_enabled[0] != 0; seed.has_memcard1_enabled = true; seed.memcard2_enabled = ls.memcard_enabled[1] != 0; seed.has_memcard2_enabled = true; +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) + seed.multitap_enabled = ls.multitap_enabled != 0; + seed.has_multitap_enabled = true; + multitap_enabled = seed.multitap_enabled; + apply_offline_pad_count(game_players, multitap_enabled); +#endif +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) + seed.multitap_analog = ls.multitap_analog != 0; + seed.has_multitap_analog = true; + multitap_analog = seed.multitap_analog; + sio_set_multitap_analog(multitap_analog ? 1 : 0); + if (game_config_path && game_config_path[0]) { + (void)PSXRecompV4::upsert_game_toml_controller_bool( + game_config_path, "multitap_analog", multitap_analog); + } +#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; } @@ -9882,6 +9959,11 @@ int main(int argc, char** argv) { if (ls.netplay_launch.enabled) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) { + /* Host-authoritative DualShock-on-tap for the match. */ + multitap_analog = caps->multitap_analog != 0; + seed.multitap_analog = multitap_analog; + seed.has_multitap_analog = true; + sio_set_multitap_analog(multitap_analog ? 1 : 0); seed.aspect_num = caps->aspect_num ? caps->aspect_num : seed.aspect_num; seed.aspect_den = caps->aspect_den ? caps->aspect_den : seed.aspect_den; seed.has_aspect_ratio = true; @@ -11114,6 +11196,13 @@ soft_return_lobby: std::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s", psx_lobby_display_name()); std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", bios_path_str.c_str()); +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) + ls.multitap_enabled = multitap_enabled ? 1 : 0; +#endif +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) + ls.multitap_analog = multitap_analog ? 1 : 0; + g_lnch_multitap_analog = multitap_analog ? 1 : 0; +#endif /* Preserve input devices across soft-return. A zeroed ls left every * player_src at None, so the rematch UI (and a subsequent writeback) * looked like netplay had wiped the pad assignment. */ @@ -11196,6 +11285,18 @@ soft_return_lobby: disc_path_str = resolved_disc.string(); } if (ls.netplay_launch.enabled) { + { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) { + multitap_analog = caps->multitap_analog != 0; + g_lnch_multitap_analog = multitap_analog ? 1 : 0; + sio_set_multitap_analog(multitap_analog ? 1 : 0); + } else { + multitap_analog = ls.multitap_analog != 0; + g_lnch_multitap_analog = multitap_analog ? 1 : 0; + sio_set_multitap_analog(multitap_analog ? 1 : 0); + } + } net_cfg = {}; net_cfg.enabled = 1; net_cfg.local_slot = ls.netplay_launch.local_slot; @@ -11264,6 +11365,23 @@ soft_return_lobby: } us.deadzone = player_deadzone[0]; us.has_deadzone = true; +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) + multitap_enabled = ls.multitap_enabled != 0; + us.multitap_enabled = multitap_enabled; + us.has_multitap_enabled = true; + apply_offline_pad_count(game_players, multitap_enabled); +#endif +#if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) + multitap_analog = ls.multitap_analog != 0; + us.multitap_analog = multitap_analog; + us.has_multitap_analog = true; + g_lnch_multitap_analog = multitap_analog ? 1 : 0; + sio_set_multitap_analog(multitap_analog ? 1 : 0); + if (game_config_path && game_config_path[0]) { + (void)PSXRecompV4::upsert_game_toml_controller_bool( + game_config_path, "multitap_analog", multitap_analog); + } +#endif us.renderer = ls.renderer; us.has_renderer = true; us.supersampling = ls.supersampling; diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index d9ff3c8f..9b4e208f 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -1276,6 +1276,7 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out) out->force_turn = json_get_bool(obj, "force_turn", 0); /* Absent field → delay-sync (older hosts). New hosts always publish explicit. */ out->rollback = json_get_bool(obj, "rollback", 0); + out->multitap_analog = json_get_bool(obj, "multitap_analog", 0); json_get_str(obj, "language", out->language, sizeof(out->language)); out->valid = 1; } @@ -1306,7 +1307,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch "\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s," "\"auto_skip_fmv\":%s,\"input_delay\":%d,\"input_prediction\":%d," "\"force_input_relay\":%s,\"force_turn\":%s,\"rollback\":%s," - "\"language\":\"%s\"}", + "\"multitap_analog\":%s,\"language\":\"%s\"}", caps->aspect_num, caps->aspect_den, caps->turbo_loads ? "true" : "false", caps->bios_hle ? "true" : "false", @@ -1317,6 +1318,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch caps->force_input_relay ? "true" : "false", caps->force_turn ? "true" : "false", caps->rollback ? "true" : "false", + caps->multitap_analog ? "true" : "false", lang); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 4e8f26b6..badc5f8d 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -164,8 +164,10 @@ static void force_session_pads_connected(int slot_count) sio_set_multitap(0); for (i = 0; i < slot_count; ++i) { sio_connect_pad(i); - /* Multitap taps are plain digital (sio clamps); lone port pad may be DS. */ - sio_set_pad_config_capable(i, sio_pad_on_multitap(i) ? 0 : 1); + /* Tap seats are digital unless multitap_analog hack is armed. */ + const int force_dig = + sio_pad_on_multitap(i) && !sio_get_multitap_analog(); + sio_set_pad_config_capable(i, force_dig ? 0 : 1); } } @@ -2099,14 +2101,15 @@ static void apply_pad_slot(int slot, const PsxNetPad *pad) g_sio_pad_prev[slot] = pad->buttons; g_sio_pad_have[slot] = 1u; } + const int force_dig = on_tap && !sio_get_multitap_analog(); sio_set_pad_connected(slot, 1); - sio_set_pad_config_capable(slot, on_tap ? 0 : 1); + sio_set_pad_config_capable(slot, force_dig ? 0 : 1); sio_set_pad_state_slot(slot, pad->buttons); - if (on_tap) + if (force_dig) sio_set_pad_sticks(slot, 0x80, 0x80, 0x80, 0x80); else sio_set_pad_sticks(slot, pad->lx, pad->ly, pad->rx, pad->ry); - sio_request_pad_type(slot, (!on_tap && pad->analog) ? 1 : 0); + sio_request_pad_type(slot, (!force_dig && pad->analog) ? 1 : 0); if (psx_start_consumer_enabled()) { uint32_t sim = g_np.session ? rnet_session_sim_tick(g_np.session) : 0u; psx_start_consumer_note(slot, sim, pad->buttons); diff --git a/runtime/src/sio.c b/runtime/src/sio.c index bdf6fb72..47a4660e 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -69,6 +69,8 @@ static uint8_t pad_connected = 0; static int sio_multitap_enabled = 0; /* Physical SIO port hosting the tap: 0 = console Port 1, 1 = Port 2. */ static int sio_multitap_port = 0; +/* Opt-in DualShock on tap seats (default off — faithful digital taps). */ +static int sio_multitap_analog_hack = 0; /* Pad communication state machine */ typedef enum { @@ -794,12 +796,20 @@ int sio_get_multitap_port(void) { #endif } +void sio_set_multitap_analog(int enabled) { + sio_multitap_analog_hack = enabled ? 1 : 0; +} + +int sio_get_multitap_analog(void) { + return sio_multitap_analog_hack; +} + int sio_pad_on_multitap(int logical_slot) { #if PSX_MAX_PLAYERS >= 5 if (!sio_multitap_active()) return 0; if (logical_slot < 0 || logical_slot >= PSX_MAX_PLAYERS) return 0; - /* Dual tap: every seat is a tap pad (digital). Single tap: opposite-port - * lone pad may stay DualShock; every tap slot is digital. */ + /* Dual tap: every seat is a tap pad. Single tap: opposite-port lone pad + * may stay DualShock; every other slot is on the tap. */ if (sio_dual_multitap()) return 1; return (logical_slot == mtap_standalone_logical()) ? 0 : 1; #else @@ -808,6 +818,11 @@ int sio_pad_on_multitap(int logical_slot) { #endif } +/* Tap seat that must stay digital unless the analog hack is armed. */ +static int sio_tap_force_digital(int logical_slot) { + return sio_pad_on_multitap(logical_slot) && !sio_multitap_analog_hack; +} + void sio_connect_pad(int slot) { if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_connected |= (uint8_t)(1u << slot); @@ -821,7 +836,7 @@ void sio_set_pad_connected(int slot, int connected) { void sio_set_pad_config_capable(int slot, int capable) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; - if (sio_pad_on_multitap(slot)) capable = 0; + if (sio_tap_force_digital(slot)) capable = 0; pad_supports_config[slot] = capable ? 1 : 0; /* A plain digital pad can never be in config mode; clear any stale latch so * the next poll reports the digital id (0x41), not the config id (0xF3). */ @@ -844,7 +859,7 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons) { void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; - if (sio_pad_on_multitap(slot)) { + if (sio_tap_force_digital(slot)) { enabled = 0; lx = ly = rx = ry = 0x80; } @@ -866,7 +881,7 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int analog) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; - if (sio_pad_on_multitap(slot)) analog = 0; + if (sio_tap_force_digital(slot)) analog = 0; int want = analog ? 1 : 0; pad_type_req[slot] = (pad_analog[slot] == want) ? -1 : (int8_t)want; }