Files
psxrecomp/runtime/tests/test_launcher_pad_mode_resolve.cpp
T
Shokunin bd65c814f7 Run the full session start on the lobby rematch
The lobby rematch re-enters the emulator through goto session_reboot,
below the first-boot session block. After its commit (offline) or
netplay clear it reset mod-owned presentation state, but it never ran
mod_runtime_activate_plugins() or the controller, load-acceleration and
disc-speed resets. So an offline rematch with mods enabled applied the
plan's main-EXE and disc patches and ran its VBlank callbacks, but no
activation callback ran, and the function-entry hook table, which the
commit empties and only activation rebuilds, stayed empty. It also
mounted the stock disc even when the plan had built a derived disc
image.

start_mod_session() now holds the first-boot sequence, unchanged:
clear the controller-mode overrides and presentation policies, load
acceleration and disc speed; run reset_mod_owned_presentation();
activate; then apply the netplay local viewport, the controller-mode
overrides and load acceleration. The first boot and the rematch both
call it after their commit or netplay clear, and both then take the
mounted disc from session_disc_path(), which returns the plan's derived
image when there is one and otherwise the stock disc. Activation still
precedes renderer and window creation on the rematch, because those
happen after session_reboot on every session. The rematch re-applies
its 4:3 Settings clamp before the session start, so a plugin's
activation or the netplay local viewport can still set the aspect.

A netplay rematch clears the plan, so activating it runs nothing and
the match stays vanilla. The first boot's behaviour is unchanged.

Tests: test_mod_owned_display_controls.py now requires activation and
the mod-owned reset to happen only inside start_mod_session, in the
order reset, activate, apply. It requires both paths to call it after
their commit and before the disc mount, and the rematch 4:3 clamp to
come before it. mod_runtime_test adds the lobby rematch sequence:
activating after a netplay clear runs no callback, VBlank plugin or
hook; the offline rematch's commit and activation re-run the
activation callback and VBlank plugin and arm exactly the plan's hook.
test_mod_load_acceleration.py follows the reset block into the lambda body.
Two comments that said a soft return never re-applies the controller-mode
override are corrected: test_launcher_pad_mode_wiring.py and
test_launcher_pad_mode_resolve.cpp. MOD_PACKAGES.md now describes the
rematch as a full session start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:42:17 -07:00

107 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 first-boot block that applied
// g_mod_controller_mode_override, so a soft return from the lobby did not
// re-run it (the rematch now runs start_mod_session() itself). The
// override survived 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;
}