Files
psxrecomp/runtime/include/launcher_device.h
T
Matthew Stanley c5bac02214 controller: re-clamp lock_mode across the launcher round-trip
Ape Escape declares `default_mode = "analog"` + `lock_mode = true`
(ApeEscapeRecomp/game.toml:208-209) and shipped with the left stick acting as
camera and the right stick doing nothing -- i.e. the pad booted DIGITAL on a
dual-analog title, and because lock_mode hides the pad-mode selector there was
no UI with which to fix it.

The root cause is in recomp-ui and is fixed in the companion PR (see below).
This is the host side of the same defect: three separate places in main() where
a locked title's declared pad mode could be lost or where a keyboard seat's
configured mode was destroyed on its way past the launcher.

WHAT WENT WRONG, END TO END
---------------------------
1. recomp-ui launcher_model.c honours the lock at line 545-546
   (`if (!pad_mode_selectable) pad_mode[p] = locked_pad_mode`) and then
   clobbers it 15 lines later with an ungated keyboard coercion. Player 1's
   device defaults to "keyboard" in a release build (main.cpp:11164), so
   pad_mode[0] came back as 2 (D-Pad) from every fresh install.
2. `launcher_model_set_pad_mode` and the default-for-source helper both
   early-return when the mode is not selectable -- correctly, since the mode
   is locked -- so once the value was 2 nothing could move it back. Attaching
   a real DualShock did not help.
3. Here, the launcher-exit readback took `player_mode[i] = ls.pad_mode[i]`
   verbatim and then persisted it into settings.toml, so DIGITAL beat the
   lock and kept beating it on every later launch.
4. `sio_set_pad_analog(slot, 0, ...)` then took the 4-byte pad_response_len
   branch in runtime/src/sio.c: the pad reports no analog bytes, so the right
   stick is dead and the left stick is folded onto the D-pad. Exactly the
   reported symptom.

The clamp at main.cpp:11869 already existed and already covered two holes
(launcher-less builds, and a settings.toml written before the game declared
lock_mode). It runs BEFORE the launcher, which is why the launcher round-trip
was a third hole it could not see.

WHAT THIS CHANGES
-----------------
runtime/include/launcher_device.h gains
`resolve_player_mode_after_launcher(launcher_mode, lock_mode, locked_mode,
mod_override_mode)`: one pure function that fixes the precedence of the three
sources of truth for a seat's pad mode -- an active mod controller-mode
override, then game.toml lock_mode, then whatever the launcher returned. Both
launcher-exit readback loops call it, and both call it before their persistence
write, so an unsupported mode can no longer be latched into settings.toml.

The mod-override arm is not decoration. A bare `if (ctrl_lock_mode)
player_mode[i] = ctrl_locked_mode[i];` -- which is the obvious fix and was the
first version of this change -- breaks mod controller-mode overrides on locked
titles after a lobby soft return:

  * `g_mod_controller_mode_override` is applied at main.cpp:13084-13085,
  * the `session_reboot:` label is BELOW it at 13159,
  * `goto session_reboot` (14914) therefore never re-runs the override block,
  * but the soft-return readback loop does run.

Before this change an override survived a rematch only because it round-tripped
through `ls.pad_mode[i]`, seeded at the soft-return entry from the post-override
`player_mode[i]`. An unconditional clamp cuts that path and silently drops the
override. `psx_mod_set_controller_mode_override` is public plugin API
(runtime/include/mod_plugins.h:229) with no shipped caller today, so this is
latent rather than live -- fixed anyway, because the next caller would have had
no way to tell why their override stopped applying to a locked title after a
rematch.

Three destructive coercions are also removed, all of the same shape -- a
keyboard seat having its configured mode overwritten with DIGITAL:

  * the two launcher-entry seeds (`ls.pad_mode[i] = (src == 1) ? DIGITAL :
    player_mode[i]`) told the launcher a lie about what the seat is configured
    for, and the launcher handed the lie straight back to be persisted;
  * the launcher-exit readback did the same in the other direction.

None of them was load-bearing: `effective_player_mode()` (main.cpp:4359-4362)
already returns DIGITAL for any seat whose kind is keyboard, whatever the
seat's stored mode says, and every consumer of a seat mode goes through it or
through `effective_player_mode_for_sio()` -- `refresh_player_devices`,
`capture_pad_slot`, and the netplay sample path were all checked. So a keyboard
seat still presents as a plain SCPH-1080 while the keyboard is driving it; the
difference is that its stored mode is no longer corrupted for the pad that
arrives later.

Note the unlocked case was never as bad, and this is worth writing down because
it is what made the bug look title-specific: for a selectable title,
`launcher_model_set_source` -> `apply_default_pad_mode_for_source` re-defaults a
gamepad seat to Analog, so picking a device in the launcher repairs the value.
That is precisely the repair a locked title cannot perform, because both of
those functions refuse to touch a locked mode.

TESTS
-----
There was zero coverage for any of this. (ApeEscapeRecomp's own
`ape_stick_response_test` covers the stick RESPONSE CURVE, not mode or axis
routing, and lives in the title repo -- it cannot see this.)

  * runtime/tests/test_launcher_pad_mode_resolve.cpp -- behaviour of the
    resolver: a locked title yields its declared mode for every launcher value
    including out-of-range ones (so persistence can never receive an
    unsupported mode), an active override outranks the lock, and the "no
    override" sentinel stays negative to match the direct apply site.
    Registered as launcher_pad_mode_resolve_test.
  * runtime/tests/test_launcher_pad_mode_wiring.py -- how it is WIRED: exactly
    two readback sites call the resolver, the resolution precedes each
    persistence write, the pre-launcher clamp still runs first, and
    `player_mode[i] = PSXRecompV4::PAD_MODE_DIGITAL;` appears nowhere. Verified
    to go red when the coercion is re-introduced at either site, not just when
    the helper is deleted. Registered as launcher_pad_mode_wiring alongside the
    other source-invariant guards in recompiler/CMakeLists.txt.

Both call sites were confirmed to be live code, not preprocessed-out, by
injecting a bad symbol name and reading the compiler errors back: main.cpp:12701
compiles under `-DRECOMP_LAUNCHER` and main.cpp:14665 additionally needs
`PSX_HAS_LOBBY_CLIENT` (i.e. `-DPSX_NETPLAY=ON`), so the second site is only
exercised in a netplay build and was verified in one.

Verified: launcher_pad_mode_resolve_test, launcher_device_roundtrip_test,
host_keymap_test, keyboard_pad_chord_test, sio_card_protocol_test all pass
(sio_dualshock_rumble_test is registered DISABLED); launcher_pad_mode_wiring and
every other python guard that reads main.cpp pass, except two that were already
red before this branch -- runtime_perf_diag_guards (registered DISABLED) and
launcher_vulkan_option, which dies in `Path.read_text()` with no encoding on a
cp1252 console. That last one is a real latent break in 13 guard scripts and
wants its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:11:25 -07:00

86 lines
3.7 KiB
C++

// launcher_device.h - controller-source conversion at the recomp-ui C ABI.
#pragma once
#include <algorithm>
#include <cctype>
#include <string>
namespace PSXRecompV4 {
inline std::string trim_launcher_device(const std::string& device) {
const auto first = std::find_if_not(device.begin(), device.end(),
[](unsigned char c) { return std::isspace(c) != 0; });
const auto last = std::find_if_not(device.rbegin(), device.rend(),
[](unsigned char c) { return std::isspace(c) != 0; }).base();
return first < last ? std::string(first, last) : std::string();
}
inline std::string normalize_launcher_device(const std::string& device) {
std::string normalized = trim_launcher_device(device);
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return normalized;
}
inline int launcher_source_from_device(const std::string& device) {
const std::string normalized = normalize_launcher_device(device);
if (normalized.empty() || normalized == "none") return 0;
if (normalized == "keyboard") return 1;
return 2;
}
inline std::string launcher_device_from_source(
int source, const std::string& previous_device) {
if (source <= 0) return "none";
if (source == 1) return "keyboard";
// recomp-ui's C ABI currently returns a source category, not the selected
// controller GUID. Preserve an existing gamepad/GUID assignment; when the
// user switched from None/Keyboard, persist the runtime's first-pad alias.
if (launcher_source_from_device(previous_device) == 2) {
return trim_launcher_device(previous_device);
}
return "gamepad";
}
// Pad mode for one seat on the way OUT of the launcher.
//
// Three sources of truth have to be reconciled, and they have a strict
// precedence:
//
// 1. A trusted mod's controller-mode override
// (psx_mod_set_controller_mode_override, runtime/include/mod_plugins.h).
// A game plugin that declared the pad type its patched code needs
// outranks everything else. Pass -1 when no override is active.
// 2. game.toml [controller] lock_mode. A locked title supports exactly one
// pad type, and the launcher HIDES its pad-mode selector for such a
// title -- so whatever came back in ls.pad_mode[] for that seat was
// never a player choice and must not be believed.
// 3. What the launcher returned.
//
// Both launcher-exit readback loops in runtime/src/main.cpp call this, and
// both call it BEFORE the seat's mode is written to settings.toml, so an
// unsupported mode can never be persisted either.
//
// Note what is deliberately NOT here: a keyboard seat is not forced to
// DIGITAL. It already behaves as a plain digital pad -- effective_player_mode()
// reports DIGITAL for any seat whose kind is keyboard, whatever this value
// says -- so overwriting the stored mode bought nothing and cost a durable
// corruption. A release install defaults Player 1 to the keyboard, and a
// locked title has no selector with which to repair the value, so an
// ANALOG-locked dual-analog game (Ape Escape) reached its DualShock as an
// SCPH-1080: sio.c took the 4-byte pad_response_len branch, right stick dead
// and left stick folded onto the D-pad. The matching launcher-side coercion is
// fixed in recomp-ui's launcher_model.c.
inline int resolve_player_mode_after_launcher(int launcher_mode,
bool lock_mode,
int locked_mode,
int mod_override_mode) {
if (mod_override_mode >= 0) return mod_override_mode;
if (lock_mode) return locked_mode;
return launcher_mode;
}
} // namespace PSXRecompV4