Files
psxrecomp/runtime/tests/test_launcher_pad_mode_resolve.cpp
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

106 lines
5.4 KiB
C++

// Pad-mode resolution at the launcher exit seam
// (PSXRecompV4::resolve_player_mode_after_launcher, runtime/include/launcher_device.h).
//
// What this guards, in order of how it broke:
//
// 1. A locked title must boot the pad type its game.toml declared, whatever
// the launcher hands back. Ape Escape sets default_mode = "analog" +
// lock_mode = true; it booted DIGITAL because the launcher returned 2 for
// Player 1 (whose device defaults to keyboard on a release install) and the
// old readback loop took ls.pad_mode[] verbatim. sio.c then answered with
// the 4-byte digital pad response: right stick dead, left stick folded onto
// the D-pad, and lock_mode hides the selector that could have fixed it.
//
// 2. An ACTIVE mod controller-mode override outranks the lock. The first
// version of the clamp was an unconditional `if (lock) mode = locked`,
// which is correct for (1) and silently wrong here: `goto session_reboot`
// re-enters the emulator BELOW the block that applies
// g_mod_controller_mode_override, so a soft return from the lobby never
// re-runs it. The override used to survive a rematch only because it
// round-tripped through ls.pad_mode[]; the clamp cut that path.
//
// 3. Whatever this returns is what gets persisted (seed.p_mode[] /
// us.p_mode[]), so a mode the game does not support must never come out of
// here for a locked title -- not even when the launcher returns garbage.
//
// PadMode values are the engine's (recompiler/src/config_loader.h:
// PAD_MODE_ANALOG = 1, PAD_MODE_DIGITAL = 2; 0 is Hybrid, mod-only). They are
// hard-coded here rather than dragged in through config_loader.h's dependency
// chain -- tests/test_launcher_pad_mode_wiring.py pins the enum so this cannot
// drift.
#include "launcher_device.h"
#include <cassert>
#include <cstdio>
namespace {
constexpr int kAnalog = 1; // PAD_MODE_ANALOG
constexpr int kDigital = 2; // PAD_MODE_DIGITAL
constexpr int kHybrid = 0; // mod-only, never selectable
constexpr int kNoOverride = -1;
int resolve(int launcher_mode, bool lock, int locked, int override_mode) {
return PSXRecompV4::resolve_player_mode_after_launcher(
launcher_mode, lock, locked, override_mode);
}
} // namespace
int main() {
// ---- unlocked: the launcher owns the seat ------------------------------
assert(resolve(kAnalog, false, kAnalog, kNoOverride) == kAnalog);
assert(resolve(kDigital, false, kAnalog, kNoOverride) == kDigital);
// The locked mode is ignored entirely when lock_mode is off, even when it
// disagrees -- ctrl_locked_mode[] is populated for every title, locked or
// not (main.cpp repopulates it outside the has_default_mode guard).
assert(resolve(kDigital, false, kAnalog, kNoOverride) == kDigital);
assert(resolve(kAnalog, false, kDigital, kNoOverride) == kAnalog);
// ---- locked: the game owns the seat (the reported bug) -----------------
// Ape Escape: ANALOG-locked, launcher returns DIGITAL because Player 1's
// device defaulted to keyboard. Must come out ANALOG.
assert(resolve(kDigital, true, kAnalog, kNoOverride) == kAnalog);
// And the other direction: a digital-only title (X4, Tomba 2) whose
// settings.toml predates the lock and still says analog.
assert(resolve(kAnalog, true, kDigital, kNoOverride) == kDigital);
// Idempotent when the launcher already agrees.
assert(resolve(kAnalog, true, kAnalog, kNoOverride) == kAnalog);
assert(resolve(kDigital, true, kDigital, kNoOverride) == kDigital);
// ---- persistence safety: a locked seat NEVER yields the launcher's
// value, so seed.p_mode[]/us.p_mode[] cannot receive an unsupported mode
// however wrong ls.pad_mode[] is (stale Hybrid, an ABI-width change, a
// settings.toml hand-edited to nonsense). -------------------------------
for (int bogus = -8; bogus <= 8; ++bogus) {
assert(resolve(bogus, true, kAnalog, kNoOverride) == kAnalog);
assert(resolve(bogus, true, kDigital, kNoOverride) == kDigital);
}
// Hybrid specifically: mod-only, and the launcher migrates it away, but a
// locked title must not adopt it even if one leaks through.
assert(resolve(kHybrid, true, kAnalog, kNoOverride) == kAnalog);
// ---- an active mod override wins over the lock ------------------------
// psx_mod_set_controller_mode_override() only ever stores ANALOG or
// DIGITAL (it rejects anything else loudly), so those are the two real
// override values.
assert(resolve(kDigital, true, kAnalog, kDigital) == kDigital);
assert(resolve(kAnalog, true, kDigital, kAnalog) == kAnalog);
// ...and over an unlocked launcher value too.
assert(resolve(kAnalog, false, kAnalog, kDigital) == kDigital);
assert(resolve(kDigital, false, kDigital, kAnalog) == kAnalog);
// ---- the "no override" sentinel is negative, matching the direct apply
// site (`if (g_mod_controller_mode_override[i] >= 0)` in main.cpp). Pinned
// so the two cannot drift into disagreeing about what "unset" means: a
// mode value of 0 would count as ACTIVE here, exactly as it does there.
assert(resolve(kAnalog, true, kDigital, -1) == kDigital);
assert(resolve(kAnalog, true, kDigital, -2) == kDigital);
assert(resolve(kDigital, false, kAnalog, -1) == kDigital);
assert(resolve(kDigital, true, kAnalog, kHybrid) == kHybrid);
std::puts("launcher pad-mode resolution guard passed");
return 0;
}