Android: audio backend options, setting descriptions, RA/haptics polish, and ported GS fixes

Audio
- Optional OpenSL ES output backend for devices where the default AAudio path
  crackles, glitches or won't initialise (Settings -> Audio), plus a lightweight
  SPU2 mode that skips the reverb pipeline to free CPU on low-end devices.
- Keep the audio device alive across the in-game menu pause so Android no longer
  reclaims the idle stream and drops sound after the menu sits open (#333).

Settings
- Restored the per-setting descriptions under every GameDB Fix and Advanced
  Speedhack toggle (lost in the settings redesign).
- Per-game Reset now clears the native per-game INI, so it truly reverts to the
  global values instead of the game keeping stale overrides.
- On-screen display now defaults off; Custom stats appear on boot without a
  reset (#385).

Controls / RetroAchievements
- Vibration Strength slider scaling all rumble and touch haptics 0-200%.
- Achievement Sound Volume slider; points now show in the menu before a game
  loads; unlock sounds play with Do Not Disturb enabled.

Misc
- Drop the compiled GS shader/pipeline cache automatically on app update to
  avoid post-update graphical corruption.
- Animated XMB library-background fallback for GPUs without float-texture
  filtering.

GS correctness (ported from sashkinbro/EmuCoreX)
- Reset per-game hardware-hack HLE state on game change (Burnout bloom,
  IRem/GT channel-shuffle) so it no longer leaks across in-app game switches.
- Fix a non-strict-weak-ordering comparator in SortMultiStretchRects.
- Free the leaked m_expand_vao on the OpenGL device teardown path.
This commit is contained in:
jpolo1224
2026-07-21 20:42:35 -04:00
parent 1637c1e76a
commit 90daa091db
31 changed files with 526 additions and 127 deletions
+12 -6
View File
@@ -485,14 +485,20 @@ std::string Achievements::GetAchievementsAsJSON()
out += ",\"userName\":";
append_json_string(out, display_name.c_str());
// Player score. Only available from the persistent client (a game with
// achievements is loaded); the post-login temporary client is destroyed,
// so report -1 ("unknown") when we can't read it. The panel hides the
// points chip on -1 rather than showing a misleading 0.
// Player score. Prefer the live persistent-client value; when it's unavailable — logged in
// but no game with achievements loaded yet, e.g. the library RA menu — fall back to the score
// cached at login (Host::OnAchievementsLoginSuccess persists it to secrets). Only a genuinely
// unknown score (never logged in) stays -1, which the panel treats as "hide the chip".
const long long score_val = user
? static_cast<long long>(user->score)
: static_cast<long long>(Host::GetIntSettingValue("Achievements", "LastScore", -1));
const long long score_sc_val = user
? static_cast<long long>(user->score_softcore)
: static_cast<long long>(Host::GetIntSettingValue("Achievements", "LastScoreSoftcore", -1));
out += ",\"score\":";
out += std::to_string(user ? static_cast<long long>(user->score) : -1LL);
out += std::to_string(score_val);
out += ",\"softcoreScore\":";
out += std::to_string(user ? static_cast<long long>(user->score_softcore) : -1LL);
out += std::to_string(score_sc_val);
// RA presentation options (global [Achievements] settings) so the panel
// can show + toggle them without a second JNI poll. Defaults mirror
+2
View File
@@ -1058,6 +1058,8 @@ struct Pcsx2Config
u32 StandardVolume = 100;
u32 FastForwardVolume = 100;
bool OutputMuted = false;
// Low-end Android lever: skip the SPU2 reverb pipeline in MixCore. Off by default.
bool LightweightMode = false;
AudioBackend Backend = DEFAULT_BACKEND;
SPU2SyncMode SyncMode = DEFAULT_SYNC_MODE;
+4
View File
@@ -20,6 +20,7 @@
#include "GS/Renderers/Null/GSDeviceNone.h"
#include "GS/Renderers/Null/GSRendererNull.h"
#include "GS/Renderers/HW/GSRendererHW.h"
#include "GS/Renderers/HW/GSHwHack.h"
#include "GS/Renderers/HW/GSTextureReplacements.h"
#include "VMManager.h"
@@ -673,7 +674,10 @@ void GSThrottlePresentation()
void GSGameChanged()
{
if (GSIsHardwareRenderer())
{
GSHwHack::ResetState();
GSTextureReplacements::GameChanged();
}
if (!VMManager::HasValidVM() && GSCapture::IsCapturing())
GSCapture::EndCapture();
+6 -1
View File
@@ -966,7 +966,12 @@ void GSDevice::SortMultiStretchRects(MultiStretchRect* rects, u32 num_rects)
{
// Depending on num_rects, insertion sort may be better here.
std::sort(rects, rects + num_rects, [](const MultiStretchRect& lhs, const MultiStretchRect& rhs) {
return lhs.src < rhs.src || lhs.filter < rhs.filter;
// Strict weak ordering: only tie-break on filter when src is equal. The old
// `lhs.src < rhs.src || lhs.filter < rhs.filter` is not a valid comparator
// (it can report both a<b and b<a), which is undefined behaviour in std::sort.
if (lhs.src != rhs.src)
return lhs.src < rhs.src;
return lhs.filter < rhs.filter;
});
}
+48 -36
View File
@@ -10,6 +10,28 @@
static bool s_nativeres;
// Per-game state for GSC hacks — reset on game change via ResetState().
static bool s_irem_first_shuffle = false;
static u32 s_burnout_state = 0;
static GIFRegTEX0 s_burnout_main_fb;
static GSVector2i s_burnout_main_fb_size;
static GIFRegTEX0 s_burnout_downsample_fb;
static GIFRegTEX0 s_burnout_bloom_fb;
static bool s_polyphony_shuffle_hle_active = false;
static u32 s_polyphony_shuffle_fbmsk = 0;
void GSHwHack::ResetState()
{
s_irem_first_shuffle = false;
s_burnout_state = 0;
memset(&s_burnout_main_fb, 0, sizeof(s_burnout_main_fb));
s_burnout_main_fb_size = GSVector2i(0, 0);
memset(&s_burnout_downsample_fb, 0, sizeof(s_burnout_downsample_fb));
memset(&s_burnout_bloom_fb, 0, sizeof(s_burnout_bloom_fb));
s_polyphony_shuffle_hle_active = false;
s_polyphony_shuffle_fbmsk = 0;
}
#define RPRIM r.PRIM
#define RCONTEXT r.m_context
@@ -39,13 +61,11 @@ static bool s_nativeres;
bool GSHwHack::GSC_IRem(GSRendererHW& r, int& skip)
{
static bool first_shuffle = false;
if (skip > 0)
{
if (skip == 1 && first_shuffle)
if (skip == 1 && s_irem_first_shuffle)
{
first_shuffle = false;
s_irem_first_shuffle = false;
GIFRegTEX0 RTLookup = GIFRegTEX0::Create(RTBP0, RFBW, RFPSM);
GSTextureCache::Source* src = g_texture_cache->LookupSource(true, RTLookup, r.m_cached_ctx.TEXA, r.m_cached_ctx.CLAMP, GSVector4i(0, 0, 1, 1), nullptr, true, false, r.m_cached_ctx.FRAME, true, true);
@@ -89,7 +109,7 @@ bool GSHwHack::GSC_IRem(GSRendererHW& r, int& skip)
else
{
skip--;
return !first_shuffle;
return !s_irem_first_shuffle;
}
}
@@ -169,7 +189,7 @@ bool GSHwHack::GSC_IRem(GSRendererHW& r, int& skip)
rt = nullptr;
src = nullptr;
first_shuffle = true;
s_irem_first_shuffle = true;
}
}
}
@@ -443,12 +463,7 @@ bool GSHwHack::GSC_BurnoutGames(GSRendererHW& r, int& skip)
// After this, they do a blur on the buffer, which is fine, because all the buffer swap BS has
// finished, so we can return to normal.
static u32 state = 0;
static GIFRegTEX0 main_fb;
static GSVector2i main_fb_size;
static GIFRegTEX0 downsample_fb;
static GIFRegTEX0 bloom_fb;
switch (state)
switch (s_burnout_state)
{
case 0: // waiting for double striped clear
{
@@ -466,33 +481,33 @@ bool GSHwHack::GSC_BurnoutGames(GSRendererHW& r, int& skip)
break;
// Clear temp render target.
main_fb = tgt->m_TEX0;
main_fb_size = tgt->GetUnscaledSize();
s_burnout_main_fb = tgt->m_TEX0;
s_burnout_main_fb_size = tgt->GetUnscaledSize();
r.m_cached_ctx.FRAME.FBW = tgt->m_TEX0.TBW;
r.m_cached_ctx.ZBUF.ZMSK = true;
r.ReplaceVerticesWithSprite(GSVector4i::loadh(main_fb_size), main_fb_size);
bloom_fb = GIFRegTEX0::Create(RFBP, RFBW, RFPSM);
state = 1;
r.ReplaceVerticesWithSprite(GSVector4i::loadh(s_burnout_main_fb_size), s_burnout_main_fb_size);
s_burnout_bloom_fb = GIFRegTEX0::Create(RFBP, RFBW, RFPSM);
s_burnout_state = 1;
GL_INS("GSC_BurnoutGames(): Initial double-striped clear.");
return true;
}
case 1: // reverse blend to extract bright pixels
{
r.ReplaceVerticesWithSprite(GSVector4i::loadh(main_fb_size), main_fb_size);
r.ReplaceVerticesWithSprite(GSVector4i::loadh(s_burnout_main_fb_size), s_burnout_main_fb_size);
r.m_cached_ctx.ZBUF.ZMSK = true;
state = 2;
s_burnout_state = 2;
GL_INS("GSC_BurnoutGames(): Extract Bright Pixels.");
return true;
}
case 2: // downsample
{
const GSVector4i downsample_rect = GSVector4i(0, 0, ((main_fb_size.x / 2)), ((main_fb_size.y / 2)));
const GSVector4i uv_rect = GSVector4i(0, 0, main_fb_size.x, main_fb_size.y);
r.ReplaceVerticesWithSprite(downsample_rect, uv_rect, main_fb_size, downsample_rect);
downsample_fb = GIFRegTEX0::Create(RFBP, RFBW, RFPSM);
state = 3;
const GSVector4i downsample_rect = GSVector4i(0, 0, ((s_burnout_main_fb_size.x / 2)), ((s_burnout_main_fb_size.y / 2)));
const GSVector4i uv_rect = GSVector4i(0, 0, s_burnout_main_fb_size.x, s_burnout_main_fb_size.y);
r.ReplaceVerticesWithSprite(downsample_rect, uv_rect, s_burnout_main_fb_size, downsample_rect);
s_burnout_downsample_fb = GIFRegTEX0::Create(RFBP, RFBW, RFPSM);
s_burnout_state = 3;
GL_INS("GSC_BurnoutGames(): Downsampling.");
// Fix up the texture width so the native scaling code can properly detect it as a downscale.
RTBW = RFBW * 2;
@@ -503,14 +518,14 @@ bool GSHwHack::GSC_BurnoutGames(GSRendererHW& r, int& skip)
{
// Kill the downsample source, because we made it way larger than it was supposed to be.
// That way we don't risk confusing any other targets.
g_texture_cache->InvalidateVideoMemType(GSTextureCache::RenderTarget, bloom_fb.TBP0);
state = 4;
g_texture_cache->InvalidateVideoMemType(GSTextureCache::RenderTarget, s_burnout_bloom_fb.TBP0);
s_burnout_state = 4;
[[fallthrough]];
}
case 4: // Skip until it's downsampled again.
{
if (!RTME || RTBP0 != downsample_fb.TBP0)
if (!RTME || RTBP0 != s_burnout_downsample_fb.TBP0)
{
GL_INS("GSC_BurnoutGames(): Skipping extra pass.");
skip = 1;
@@ -520,7 +535,7 @@ bool GSHwHack::GSC_BurnoutGames(GSRendererHW& r, int& skip)
// Finally, we're done, let the game take over.
GL_INS("GSC_BurnoutGames(): Bloom effect done.");
skip = 0;
state = 0;
s_burnout_state = 0;
return true;
}
}
@@ -692,13 +707,10 @@ bool GSHwHack::GSC_PolyphonyDigitalGames(GSRendererHW& r, int& skip)
// Need to track the FBMSK as well. The transition at the start of the race does both an RGB
// and A shuffle, but obviously changes FBMSK mid-way, so we can restart then.
static bool shuffle_hle_active = false;
static u32 shuffle_fbmsk = 0;
const bool is_cs = r.IsPossibleChannelShuffle();
if (shuffle_hle_active && is_cs)
if (s_polyphony_shuffle_hle_active && is_cs)
{
if (RFBMSK == shuffle_fbmsk)
if (RFBMSK == s_polyphony_shuffle_fbmsk)
{
skip = 1;
return true;
@@ -706,7 +718,7 @@ bool GSHwHack::GSC_PolyphonyDigitalGames(GSRendererHW& r, int& skip)
}
else if (!is_cs)
{
shuffle_hle_active = false;
s_polyphony_shuffle_hle_active = false;
return false;
}
@@ -723,8 +735,8 @@ bool GSHwHack::GSC_PolyphonyDigitalGames(GSRendererHW& r, int& skip)
return false;
// skip this draw, and until the end of the CS, ignoring fbmsk and cbp
shuffle_hle_active = true;
shuffle_fbmsk = RFBMSK;
s_polyphony_shuffle_hle_active = true;
s_polyphony_shuffle_fbmsk = RFBMSK;
skip = 1;
const u32 fbmsk = RFBMSK;
+1
View File
@@ -6,6 +6,7 @@
class GSHwHack
{
public:
static void ResetState();
static bool GSC_IRem(GSRendererHW& r, int& skip);
static bool GSC_Manhunt2(GSRendererHW& r, int& skip);
static bool GSC_SacredBlaze(GSRendererHW& r, int& skip);
+2
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0+
#include "GS/Renderers/HW/GSRendererHW.h"
#include "GS/Renderers/HW/GSHwHack.h"
#include "GS/Renderers/HW/GSTextureReplacements.h"
#include "GS/GSGL.h"
#include "GS/GSPerfMon.h"
@@ -87,6 +88,7 @@ void GSRendererHW::Reset(bool hardware_reset)
g_texture_cache->ReadbackAll();
g_texture_cache->RemoveAll(true, true, true);
GSHwHack::ResetState();
GSRenderer::Reset(hardware_reset);
}
+5 -2
View File
@@ -1278,8 +1278,11 @@ void GSDeviceOGL::DestroyResources()
m_vertex_push_constants_stream_buffer.reset();
glBindVertexArray(0);
if (m_expand_ibo != 0)
glDeleteVertexArrays(1, &m_expand_ibo);
// Delete the expand VAO here (not m_expand_ibo, which is a buffer object and is
// correctly freed with glDeleteBuffers below). The old code deleted m_expand_ibo
// as a VAO — a no-op — so m_expand_vao leaked on every device teardown/recreate.
if (m_expand_vao != 0)
glDeleteVertexArrays(1, &m_expand_vao);
if (m_vao != 0)
glDeleteVertexArrays(1, &m_vao);
if (m_dummy_vao != 0)
+1
View File
@@ -795,6 +795,7 @@ void AudioStreamParameters::LoadSave(SettingsWrapper& wrap, const char* section)
{
wrap.EnumEntry(section, "ExpansionMode", expansion_mode, &AudioStream::ParseExpansionMode, &AudioStream::GetExpansionModeName, DEFAULT_EXPANSION_MODE);
minimal_output_latency = wrap.EntryBitBool(section, "OutputLatencyMinimal", DEFAULT_OUTPUT_LATENCY_MINIMAL);
android_use_opensles = wrap.EntryBitBool(section, "AndroidOpenSLES", DEFAULT_ANDROID_USE_OPENSLES);
buffer_ms = static_cast<u16>(std::clamp<int>(wrap.EntryBitfield(section, "BufferMS", buffer_ms, DEFAULT_BUFFER_MS), 0, std::numeric_limits<u16>::max()));
output_latency_ms = static_cast<u16>(std::clamp<int>(wrap.EntryBitfield(section, "OutputLatencyMS", output_latency_ms, DEFAULT_OUTPUT_LATENCY_MS), 0, std::numeric_limits<u16>::max()));
+6
View File
@@ -33,6 +33,11 @@ struct AudioStreamParameters
{
AudioExpansionMode expansion_mode = DEFAULT_EXPANSION_MODE;
bool minimal_output_latency = DEFAULT_OUTPUT_LATENCY_MINIMAL;
// Android/Oboe only: force the legacy OpenSL ES output path instead of AAudio.
// Higher latency, but a buffer-queue stream Android does not aggressively
// reclaim when idle — so pause/resume stays a cheap play-state toggle rather
// than a full stream rebuild. Ignored by every non-Oboe backend.
bool android_use_opensles = DEFAULT_ANDROID_USE_OPENSLES;
u16 buffer_ms = DEFAULT_BUFFER_MS;
u16 output_latency_ms = DEFAULT_OUTPUT_LATENCY_MS;
@@ -57,6 +62,7 @@ struct AudioStreamParameters
static constexpr u16 DEFAULT_BUFFER_MS = 50;
static constexpr u16 DEFAULT_OUTPUT_LATENCY_MS = 20;
static constexpr bool DEFAULT_OUTPUT_LATENCY_MINIMAL = false;
static constexpr bool DEFAULT_ANDROID_USE_OPENSLES = false;
static constexpr u16 DEFAULT_EXPAND_BLOCK_SIZE = 2048;
static constexpr float DEFAULT_EXPAND_CIRCULAR_WRAP = 90.0f;
+10
View File
@@ -195,6 +195,16 @@ bool OboeAudioStream::Open()
oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output);
builder.setPerformanceMode(m_perf_mode);
// Opt-in legacy OpenSL ES output. AAudio's low-latency fast path is the one
// Android silently reclaims when the stream sits idle (e.g. the in-game pause
// menu), which then forces a full Close/Open stream rebuild on resume — the
// ~1s hitch users see toggling fast-forward through the menu, and the cause of
// audio dying a few seconds into a pause (#333). OpenSL ES is a higher-latency
// buffer-queue path Android does NOT aggressively reclaim, so pause→resume
// stays a cheap requestPause/requestStart with no rebuild. Off by default; the
// trade is a little more output latency.
if (m_parameters.android_use_opensles)
builder.setAudioApi(oboe::AudioApi::OpenSLES);
builder.setSharingMode(oboe::SharingMode::Shared);
builder.setFormat(oboe::AudioFormat::Float);
builder.setSampleRate(m_sample_rate);
+2
View File
@@ -1314,6 +1314,7 @@ void Pcsx2Config::SPU2Options::LoadSave(SettingsWrapper& wrap)
SettingsWrapEntry(StandardVolume);
SettingsWrapEntry(FastForwardVolume);
SettingsWrapEntry(OutputMuted);
SettingsWrapEntry(LightweightMode);
SettingsWrapParsedEnum(Backend, "Backend", &AudioStream::ParseBackendName, &AudioStream::GetBackendName);
SettingsWrapParsedEnum(SyncMode, "SyncMode", &ParseSyncMode, &GetSyncModeName);
SettingsWrapEntry(DriverName);
@@ -1333,6 +1334,7 @@ bool Pcsx2Config::SPU2Options::operator==(const SPU2Options& right) const
OpEqu(StandardVolume) &&
OpEqu(FastForwardVolume) &&
OpEqu(OutputMuted) &&
OpEqu(LightweightMode) &&
OpEqu(Backend) &&
OpEqu(StreamParameters) &&
OpEqu(DriverName) &&
+7
View File
@@ -557,6 +557,13 @@ static __forceinline StereoOut32 MixCore(const uint coreidx, const VoiceMixSet&
TW.Left += Ext.Left & thiscore.WetGate.ExtL;
TW.Right += Ext.Right & thiscore.WetGate.ExtR;
// Lightweight audio mode (low-end Android CPU lever): keep wet-routed voices
// audible but skip the considerably heavier SPU2 reverb pipeline (the
// ReverbDownsample/Upsample FIR resamplers + comb/all-pass network in
// DoReverb). Trades all echo/spatial reverb for CPU; off by default.
if (EmuConfig.SPU2.LightweightMode)
return TD + TW;
#ifdef PCSX2_DEVBUILD
WaveDump::WriteCore(thiscore.Index, CoreSrc_PreReverb, TW);
#endif
@@ -2019,6 +2019,17 @@ void Host::BeginPresentFrame() {
void Host::OnGameChanged(const std::string& title, const std::string& elf_override, const std::string& disc_path,
const std::string& disc_serial, u32 disc_crc, u32 current_crc) {
// Free-software / anti-resale notice on each game boot, rendered through PCSX2's own OSD (the
// same message system + renderer as the FPS/stats overlay) so it reads as a native emulator
// pop-up rather than an Android layer drawn on top. Keyed so a re-fire just refreshes the one
// message. Guarded on a real game loading — OnGameChanged also fires with everything empty on
// shutdown/eject.
if (current_crc != 0 || !disc_path.empty() || !title.empty()) {
Host::AddKeyedOSDMessage("armsx2_free_software_notice",
"You are using ARMSX2, and it should not be sold, or distributed as part of any other "
"app. If you paid for this app, you should get your money back.",
10.0f);
}
}
void Host::PumpMessagesOnCPUThread() {
@@ -2309,6 +2320,13 @@ Java_kr_co_iefriends_pcsx2_NativeApp_pause(JNIEnv *env, jclass clazz) {
extern "C"
JNIEXPORT void JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_resume(JNIEnv *env, jclass clazz) {
// Always drop the audio-keep-alive suppression a menu/overlay pause may have set
// (see setOutputPauseSuppressed). Clearing it on EVERY resume — overlay close and
// lifecycle onResume alike — means it can never get stuck on and starve a later
// background/quit of a real audio pause. The stream was never paused while
// suppressed, so this doesn't itself touch the device.
SPU2::SetOutputPauseSuppressed(false);
if (!VMManager::HasValidVM())
return;
@@ -2319,6 +2337,20 @@ Java_kr_co_iefriends_pcsx2_NativeApp_resume(JNIEnv *env, jclass clazz) {
Console.WriteLn("@@ANDROID_RESUME@@ queued state=%d", static_cast<int>(VMManager::GetState()));
}
extern "C"
JNIEXPORT void JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_setOutputPauseSuppressed(JNIEnv *env, jclass clazz, jboolean suppressed) {
// Set by pauseForOverlay(true) right before the in-game menu pauses the VM: while
// suppressed, SPU2::SetOutputPaused() is a no-op so the audio device keeps running
// (underrunning to silence — no audible artifact) instead of being paused. A paused
// low-latency AAudio stream is what Android reclaims when idle, forcing a full
// Close/Open rebuild on resume — the ~1s fast-forward-from-menu hitch, and the
// "audio dies a few seconds into a paused menu" bug (#333). Keeping it alive across
// the brief menu pause means resume is a cheap no-op with no rebuild. Only the
// overlay pause sets this; background/quit pause normally, and resume() clears it.
SPU2::SetOutputPauseSuppressed(suppressed == JNI_TRUE);
}
extern "C"
JNIEXPORT void JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_flushShaderCache(JNIEnv *env, jclass clazz) {
@@ -2917,7 +2949,17 @@ void Host::RequestVMShutdown(bool allow_confirm, bool allow_save_state, bool def
void Host::OnAchievementsLoginSuccess(const char* username, u32 points, u32 sc_points, u32 unread_messages)
{
// noop
// Cache the account score so the RA panels can show it even with no game loaded. The
// persistent rc_client (and thus rc_client_get_user_info, which is where GetAchievementsAsJSON
// normally reads the score) is null until a game WITH achievements loads — so before that the
// library / in-game RA menu had no score to show and hid the points chip. Persist it beside
// the token in secrets so it survives a restart; GetAchievementsAsJSON falls back to it.
if (s_secrets_settings_interface)
{
s_secrets_settings_interface->SetIntValue("Achievements", "LastScore", static_cast<int>(points));
s_secrets_settings_interface->SetIntValue("Achievements", "LastScoreSoftcore", static_cast<int>(sc_points));
s_secrets_settings_interface->Save();
}
}
void Host::OnAchievementsLoginRequested(Achievements::LoginRequestReason reason)
@@ -3507,6 +3549,27 @@ Java_kr_co_iefriends_pcsx2_NativeApp_osdApplyFlags(JNIEnv*, jclass,
// next boot via UpdateGameSettingsLayer.
static std::unique_ptr<INISettingsInterface> s_export_game_ini;
// The [sections] applyTo() owns and fully regenerates on each per-game write. We LOAD the
// existing file and clear only these, rather than starting from a FRESH (unloaded) interface:
// a fresh start dropped every FOREIGN key in the file, most visibly the [Patches]/[Cheats]
// "Enable" lists written by setEnabledPatches, so changing ANY in-game setting silently wiped
// that game's enabled patches. Clearing just the sections we own still drops stale overrides
// (the original intent) while leaving anything we don't own alone — robust for future keys too.
static constexpr const char* OWNED_GAME_INI_SECTIONS[] = {
"EmuCore", "EmuCore/CPU", "EmuCore/CPU/Recompiler", "EmuCore/GS",
"EmuCore/Gamefixes", "EmuCore/Speedhacks", "Framerate", "MemoryCards",
};
// Open [path] as the active export interface for the gameIniPut/gameIniCommitWrite stream that
// follows: load what's there (so foreign keys survive), then blank the sections we regenerate.
static void BeginGameIniExport(const std::string& path) {
auto ini = std::make_unique<INISettingsInterface>(path);
ini->Load(); // failure just means there was no file yet, i.e. nothing to preserve
for (const char* sec : OWNED_GAME_INI_SECTIONS)
ini->ClearSection(sec);
s_export_game_ini = std::move(ini);
}
extern "C" JNIEXPORT jboolean JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_gameIniBeginWrite(JNIEnv*, jclass) {
if (!VMManager::HasValidVM())
@@ -3519,24 +3582,34 @@ Java_kr_co_iefriends_pcsx2_NativeApp_gameIniBeginWrite(JNIEnv*, jclass) {
crc = VMManager::GetCurrentCRC();
if (crc == 0)
return JNI_FALSE;
// LOAD the existing file, then clear only the sections applyTo regenerates.
//
// This used to build a FRESH (unloaded) interface so stale per-game overrides couldn't
// linger — but that also dropped every FOREIGN key in the file, most visibly the
// [Patches]/[Cheats] "Enable" lists written by setEnabledPatches. The result was that
// changing ANY in-game setting silently wiped that game's enabled patches. Clearing just
// the sections we own still drops stale overrides (the original intent) while leaving
// anything we don't own alone — robust for future keys too, not only patches.
auto ini = std::make_unique<INISettingsInterface>(
VMManager::GetGameSettingsPath(VMManager::GetDiscSerial(), crc));
ini->Load(); // failure just means there was no file yet, i.e. nothing to preserve
static constexpr const char* OWNED_SECTIONS[] = {
"EmuCore", "EmuCore/CPU", "EmuCore/CPU/Recompiler", "EmuCore/GS",
"EmuCore/Gamefixes", "EmuCore/Speedhacks", "Framerate", "MemoryCards",
};
for (const char* sec : OWNED_SECTIONS)
ini->ClearSection(sec);
s_export_game_ini = std::move(ini);
BeginGameIniExport(VMManager::GetGameSettingsPath(VMManager::GetDiscSerial(), crc));
return JNI_TRUE;
}
// VM-less variant: rewrite a game's per-game INI when NOTHING is running — the case behind the
// per-game "Reset" not sticking from the library. With no VM there is no disc CRC to build the
// <serial>_<CRC>.ini name, and the file only exists at all if the user previously changed a
// setting IN-GAME (that's the sole writer). So glob by serial: a match means a stale override
// file the JSON prune couldn't reach, which we rewrite from the post-reset settings the Kotlin
// stream puts next; no match means there is nothing to shadow global and JNI_FALSE tells Kotlin
// to skip the (now unnecessary) put/commit.
extern "C" JNIEXPORT jboolean JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_gameIniBeginWriteForSerial(JNIEnv* env, jclass, jstring p_serial) {
if (!p_serial)
return JNI_FALSE;
const char* serial_c = env->GetStringUTFChars(p_serial, nullptr);
const std::string serial = serial_c ? serial_c : "";
if (serial_c) env->ReleaseStringUTFChars(p_serial, serial_c);
if (serial.empty())
return JNI_FALSE;
FileSystem::FindResultsArray results;
FileSystem::FindFiles(EmuFolders::GameSettings.c_str(),
fmt::format("{}_*.ini", Path::SanitizeFileName(serial)).c_str(),
FILESYSTEM_FIND_FILES, &results);
if (results.empty())
return JNI_FALSE;
// A serial normally has exactly one CRC-keyed file; rewrite that one.
BeginGameIniExport(results.front().FileName);
return JNI_TRUE;
}
@@ -146,6 +146,17 @@ data class Settings(
* CPU-bound devices; default off uses the scalar reference (unchanged
* audio). Applied on the next game boot/reset. */
val spu2NeonReverb: Boolean = false,
/** SPU2/Output/AndroidOpenSLES opt-in legacy OpenSL ES audio path (Oboe)
* instead of AAudio. Slightly higher latency, but Android doesn't reclaim
* the idle stream, so pause/resume (and fast-forward toggling through the
* menu) never triggers the ~1s stream rebuild. Applies live (stream
* reconfigures). Default off = AAudio low-latency. */
val audioOpenSLES: Boolean = false,
/** SPU2/Output/LightweightMode low-end audio lever: skip the SPU2 reverb
* pipeline (all echo/spatial reverb) in the mixer. Frees CPU on devices that
* can't keep up even with NEON reverb; default off = full reverb. Applies
* live (read per-sample in MixCore). */
val spu2LightweightMix: Boolean = false,
// ---- EmuCore — patches / cheats ----
/** EmuCore/EnablePatches — game-compatibility patches (default on). */
@@ -715,6 +726,11 @@ data class Settings(
// Opt-in NEON reverb FIR (ARM64). Read by SPU2::InternalReset on the
// next game boot; default off = scalar reference (unchanged audio).
put("SPU2", "NeonReverbSIMD", "bool", spu2NeonReverb.toString())
// Opt-in OpenSL ES output (Oboe). Lives in the SPU2/Output StreamParameters,
// so ApplySettings → CheckForConfigChanges recreates the stream on toggle.
put("SPU2/Output", "AndroidOpenSLES", "bool", audioOpenSLES.toString())
// Lightweight mix (skip reverb) — read live in MixCore via EmuConfig.SPU2.
put("SPU2/Output", "LightweightMode", "bool", spu2LightweightMix.toString())
// Patches / cheats (EmuCore). Reloaded by ApplySettings →
// CheckForPatchConfigChanges; widescreen/no-interlacing take effect on
// the next boot for most games.
@@ -922,6 +938,8 @@ data class Settings(
audioOutputLatencyMs = intAt("SPU2/Output/OutputLatencyMS") ?: this.audioOutputLatencyMs,
audioFastForwardVolume = intAt("SPU2/Output/FastForwardVolume") ?: this.audioFastForwardVolume,
spu2NeonReverb = boolAt("SPU2/NeonReverbSIMD") ?: this.spu2NeonReverb,
audioOpenSLES = boolAt("SPU2/Output/AndroidOpenSLES") ?: this.audioOpenSLES,
spu2LightweightMix = boolAt("SPU2/Output/LightweightMode") ?: this.spu2LightweightMix,
// ---- EmuCore patches / cheats ----
enablePatches = boolAt("EmuCore/EnablePatches") ?: this.enablePatches,
enableCheats = boolAt("EmuCore/EnableCheats") ?: this.enableCheats,
@@ -1154,7 +1172,7 @@ data class Settings(
* running game already reflects the change live, so the native commit does
* not reload the INI applies as the game layer on the next boot. No-op
* when no VM is running. */
fun writeGameSettingsIni(global: Settings) {
fun writeGameSettingsIni(global: Settings, serial: String? = null) {
// Baseline: global's persisted keys. applyTo early-returns before the
// live pokes/commit while emitSink is set, so nothing touches the VM.
val baseline = HashMap<String, String>()
@@ -1164,7 +1182,12 @@ data class Settings(
} finally {
emitSink = null
}
if (!NativeApp.gameIniBeginWrite()) return
// With a running VM the target is the current game (gameIniBeginWrite). With no VM — a
// per-game Reset done from the library — pass [serial] to locate the file directly; false
// there means no stale override file exists, so there is nothing to rewrite.
val began = if (serial == null) NativeApp.gameIniBeginWrite()
else NativeApp.gameIniBeginWriteForSerial(serial)
if (!began) return
// Effective pass: stream only the keys that differ from the baseline.
emitSink = { section, key, _, value ->
if (baseline["$section$key"] != value)
@@ -1469,6 +1492,8 @@ data class Settings(
put("audioOutputLatencyMs", audioOutputLatencyMs)
put("audioFastForwardVolume", audioFastForwardVolume)
put("spu2NeonReverb", spu2NeonReverb)
put("audioOpenSLES", audioOpenSLES)
put("spu2LightweightMix", spu2LightweightMix)
put("renderer", renderer)
put("upscaleFloat", upscaleFloat.toDouble())
put("customDriverId", customDriverId)
@@ -1716,6 +1741,8 @@ data class Settings(
audioOutputLatencyMs = json.optInt("audioOutputLatencyMs", def.audioOutputLatencyMs),
audioFastForwardVolume = json.optInt("audioFastForwardVolume", def.audioFastForwardVolume),
spu2NeonReverb = json.optBoolean("spu2NeonReverb", def.spu2NeonReverb),
audioOpenSLES = json.optBoolean("audioOpenSLES", def.audioOpenSLES),
spu2LightweightMix = json.optBoolean("spu2LightweightMix", def.spu2LightweightMix),
renderer = json.optString("renderer", def.renderer),
upscaleFloat = json.optDouble("upscaleFloat", def.upscaleFloat.toDouble()).toFloat(),
customDriverId = json.optString("customDriverId", def.customDriverId),
@@ -1949,6 +1976,8 @@ data class Settings(
if (current.audioOutputLatencyMs != base.audioOutputLatencyMs) j.put("audioOutputLatencyMs", current.audioOutputLatencyMs)
if (current.audioFastForwardVolume != base.audioFastForwardVolume) j.put("audioFastForwardVolume", current.audioFastForwardVolume)
if (current.spu2NeonReverb != base.spu2NeonReverb) j.put("spu2NeonReverb", current.spu2NeonReverb)
if (current.audioOpenSLES != base.audioOpenSLES) j.put("audioOpenSLES", current.audioOpenSLES)
if (current.spu2LightweightMix != base.spu2LightweightMix) j.put("spu2LightweightMix", current.spu2LightweightMix)
if (current.renderer != base.renderer) j.put("renderer", current.renderer)
if (current.upscaleFloat != base.upscaleFloat) j.put("upscaleFloat", current.upscaleFloat.toDouble())
if (current.customDriverId != base.customDriverId) j.put("customDriverId", current.customDriverId)
@@ -2163,6 +2192,8 @@ data class Settings(
audioOutputLatencyMs = if (overrides.has("audioOutputLatencyMs")) overrides.getInt("audioOutputLatencyMs") else base.audioOutputLatencyMs,
audioFastForwardVolume = if (overrides.has("audioFastForwardVolume")) overrides.getInt("audioFastForwardVolume") else base.audioFastForwardVolume,
spu2NeonReverb = if (overrides.has("spu2NeonReverb")) overrides.getBoolean("spu2NeonReverb") else base.spu2NeonReverb,
audioOpenSLES = if (overrides.has("audioOpenSLES")) overrides.getBoolean("audioOpenSLES") else base.audioOpenSLES,
spu2LightweightMix = if (overrides.has("spu2LightweightMix")) overrides.getBoolean("spu2LightweightMix") else base.spu2LightweightMix,
renderer = if (overrides.has("renderer")) overrides.getString("renderer") else base.renderer,
upscaleFloat = if (overrides.has("upscaleFloat")) overrides.getDouble("upscaleFloat").toFloat() else base.upscaleFloat,
customDriverId = if (overrides.has("customDriverId")) overrides.getString("customDriverId") else base.customDriverId,
@@ -317,6 +317,10 @@ val EN: Map<String, String> = mapOf(
"audio.swapChannels.description" to "Swaps the stereo output (L↔R). Useful when a device's Type-C port forces reverse-landscape and flips the physical speakers (e.g. the Clamp gamepad), which otherwise reverses the stereo image in racing games. Applies instantly.",
"audio.spu2Simd.label" to "SPU2 SIMD audio (experimental)",
"audio.spu2Simd.description" to "NEON fast path for reverb audio processing — frees up CPU, which can help performance on CPU-limited devices. Off (default) uses the standard path with unchanged audio. Reboot the game to switch.",
"audio.openSles.label" to "OpenSL ES audio (compatibility)",
"audio.openSles.description" to "Uses the older OpenSL ES output path instead of AAudio. A compatibility fallback for devices where the default audio glitches, crackles, or won't initialize — at the cost of slightly higher latency. Most devices should leave this off (AAudio low-latency). Applies instantly.",
"audio.lightweight.label" to "Lightweight audio (skip reverb)",
"audio.lightweight.description" to "Skips SPU2 reverb processing to save CPU on low-end devices. This removes all echo and spatial reverb (caves, halls and ambience sound flat), so only enable it if you need the extra performance and SPU2 SIMD audio isn't enough. Off (default) plays full audio. Applies instantly.",
// --- Recompiler (JIT) tab ---
"jit.recompiler.warning" to "Disabling a recompiler drops that CPU/COP onto its interpreter — much slower, for debugging only. Changes apply to the running game.",
"jit.diagnostics.header" to "Diagnostics",
@@ -755,6 +759,8 @@ val EN: Map<String, String> = mapOf(
"pad.multitap.label" to "Multitap (up to 8 players)",
"pad.rumble.description" to "Master switch for controller rumble and the device's built-in vibration. Turn off to silence all haptics.",
"pad.rumble.label" to "Rumble / Vibration",
"pad.hapticStrength.description" to "Scales all vibration — controller rumble and on-screen touch haptics alike. Below 100% tames a strong motor; above 100% boosts a weak one.",
"pad.hapticStrength.label" to "Vibration Strength",
"pad.scopeHint.global" to "○ Editing GLOBAL controls (all games).",
"pad.scopeHint.globalWithGameHint" to "○ Editing GLOBAL controls (all games). Switch to Game up top for a per-game map.",
"pad.padProfiles.info" to "Save the button map, stick modes and stick binds above under a name, then pick it again later. A profile applies to the player and scope you're editing (shown above), so you can save one map and apply it per game. Stick feel — deadzone, sensitivity, rumble — isn't part of a profile: it describes your pad, not the game. Profiles save to the inputprofiles folder, so they survive moving your data folder.",
@@ -891,6 +897,26 @@ val EN: Map<String, String> = mapOf(
"perf.fix.vuAddSub" to "VU Add-Sub",
"perf.fix.vuOverflow" to "VU Overflow",
"perf.fix.vuSync" to "VU Sync",
// Per-setting descriptions for the GameDB Fixes toggles (restored after the UI rework).
"perf.fix.skipBios.desc" to "Boots the game directly, skipping the PS2 startup/BIOS animation. Safe to leave on.",
"perf.fix.gamedbFixes.desc" to "Master switch for the compatibility fixes below, plus the automatic per-game fixes from ARMSX2's game database. Leave on unless troubleshooting.",
"perf.fix.skipMpeg.desc" to "Forces FMV videos to report as finished — a last-resort fix for games that hang on full-motion video. Best set per-game (see the warning when enabled).",
"perf.fix.fmvSoftware.desc" to "Renders FMVs with the software renderer to fix garbled or corrupted pre-rendered video in some games.",
"perf.fix.eeTiming.desc" to "Tweaks EE timing for the handful of games sensitive to it (e.g. Digital Devil Saga, SSX On Tour). Off by default.",
"perf.fix.instantDma.desc" to "Completes certain DMA transfers instantly, fixing missing text or graphics in games like Fire Pro Wrestling Returns.",
"perf.fix.blitFps.desc" to "Corrects the internal FPS reading so in-game and emulated frame counters are accurate in games that mis-report it.",
"perf.fix.fpuMultiply.desc" to "Uses a more accurate FPU multiply, fixing games that rely on exact float math (e.g. Tales of Destiny).",
"perf.fix.ophFlag.desc" to "Emulates the VU0 OPH flag, fixing hangs or missing graphics in Bleach Blade Battlers and some Tri-Ace games.",
"perf.fix.gifFifo.desc" to "Emulates the GIF FIFO accurately, fixing graphical glitches in games like FIFA Street 2 and Hot Wheels.",
"perf.fix.dmaBusy.desc" to "Delays the VIF1 DMA busy flag, fixing hangs in games such as Mana Khemia and Metal Saga.",
"perf.fix.vif1Stall.desc" to "Emulates VIF1 command stalls, fixing graphics in games that depend on precise VIF1 timing (e.g. SOCOM II).",
"perf.fix.iBit.desc" to "Handles the VU I-bit, fixing shaky or broken geometry in Scarface and Crash: Wrath of Cortex.",
"perf.fix.fullVu0Sync.desc" to "Fully synchronizes VU0 with the EE, fixing freezes and glitches in games that need tight VU0 timing.",
"perf.fix.vuAddSub.desc" to "Uses accurate VU add/subtract, fixing Tri-Ace games (Star Ocean 3, Valkyrie Profile 2, Radiata Stories).",
"perf.fix.vuOverflow.desc" to "Adds VU overflow checks, fixing missing or exploding geometry in games like Superman Returns.",
"perf.fix.extraXgkick.desc" to "Emulates extra XGKICK timing, fixing graphical glitches in games such as Erementar Gerad.",
"perf.fix.goemonTlb.desc" to "Preloads TLB entries for the Goemon games, fixing their boot hangs.",
"perf.fix.vuSync.desc" to "Runs VU1 tightly synced with the EE, fixing games that break with threaded or fast VU1 (reduces some MTVU benefit).",
"perf.frameSkip.description" to "Low-end devices: draw 1 of every (N+1) frames to free up GPU. Emulation still runs full speed; higher = choppier but faster.",
"perf.frameSkip.label" to "Frame Skip",
"perf.gamedbFixes.help" to "Compatibility shortcuts. Leave GameDB Fixes off unless a game needs one of the fixes below.",
@@ -906,6 +932,17 @@ val EN: Map<String, String> = mapOf(
"perf.hack.vuFlagHack" to "VU Flag Hack",
"perf.hack.vuNeonFusions" to "VU NEON Fusions",
"perf.hack.waitLoop" to "Wait Loop",
// Per-setting descriptions for the Advanced Speedhacks toggles (restored after the UI rework).
"perf.hack.mtvu.desc" to "Runs VU1 on its own CPU thread — faster on multi-core devices, but can break games needing tight EE/VU1 sync. On by default.",
"perf.hack.instantVu1.desc" to "Completes VU1 programs instantly instead of simulating their timing. Big speed boost; rarely causes minor glitches. On by default.",
"perf.hack.vuFlagHack.desc" to "Skips VU flag updates that aren't needed. Safe speed boost for almost all games. On by default.",
"perf.hack.fastCdvd.desc" to "Speeds up disc loading. Fixes slow loads, but can break the few games that depend on real CDVD timing. Off by default.",
"perf.hack.intcStat.desc" to "Speeds up games that poll the INTC_STAT register in a tight loop. Safe for nearly all games. On by default.",
"perf.hack.waitLoop.desc" to "Detects and skips idle EE wait-loops for extra speed. Safe for most games. On by default.",
"perf.hack.vuNeonFusions.desc" to "Uses fused NEON instructions in the VU recompiler for extra speed on ARM devices. On by default.",
"perf.hack.skipVuStallSim.desc" to "Skips simulating VU pipeline stalls for more speed. Can glitch games that need accurate VU timing. Off by default.",
"perf.hack.deferVuWrites.desc" to "Defers some VU memory writes for speed. Can cause glitches in a few games. Off by default.",
"perf.hack.skipDupeFrames.desc" to "Skips presenting duplicate frames to save GPU and battery. On by default; turn off if you notice stutter.",
"perf.ntscFramerate.description" to "Emulated refresh for NTSC (US/JP) games. Default 60 (59.94 Hz). Like NetherSX2's per-region rate; games internally at 30fps run at half this.",
"perf.ntscFramerate.label" to "NTSC Framerate (Hz)",
"perf.palFramerate.description" to "Emulated refresh for PAL (EU) games. Default 50 Hz. Games internally at 25fps run at half this.",
@@ -957,6 +994,8 @@ val EN: Map<String, String> = mapOf(
"ra.options.encoreMode" to "Encore Mode",
"ra.options.encoreMode.desc" to "Re-notify achievements you've already unlocked as you earn them again this session. For replaying a game and seeing the pop-ups.",
"ra.options.soundEffects" to "Sound Effects",
"ra.options.soundVolume" to "Sound Volume",
"ra.options.soundVolume.desc" to "Volume of the achievement unlock sound.",
"ra.options.spectatorMode" to "Spectator Mode",
"ra.options.spectatorMode.desc" to "Track achievements without sending any unlocks to the server — nothing is recorded to your account.",
"ra.options.unofficialTestMode" to "Test Unofficial Achievements",
@@ -340,6 +340,22 @@ object ControllerMappings {
kr.co.iefriends.pcsx2.NativeApp.sRumbleEnabled = on
}
// Haptic strength: one multiplier scaling ALL vibration — controller rumble AND on-screen
// touch ticks both funnel through NativeApp.rumbleOne. 0..200 % (100 = as the game/UI
// authored it), so it tames a too-strong motor or boosts a weak one. Persisted and mirrored
// into NativeApp.sHapticScale live on change and at app start (MainActivityRuntime).
private const val KEY_HAPTIC_INTENSITY = "pad.haptic.intensity"
fun hapticIntensity(): Int = MainActivityRuntime.prefs.getInt(KEY_HAPTIC_INTENSITY, 100)
fun setHapticIntensity(pct: Int) {
val clamped = pct.coerceIn(0, 200)
MainActivityRuntime.prefs.edit { putInt(KEY_HAPTIC_INTENSITY, clamped) }
kr.co.iefriends.pcsx2.NativeApp.sHapticScale = clamped / 100f
}
/** Push the persisted haptic strength into the native gate; call once at app start. */
fun syncHapticIntensity() {
kr.co.iefriends.pcsx2.NativeApp.sHapticScale = hapticIntensity() / 100f
}
// PS2 Multitap master switch. OFF (default) = classic 2-player co-op. ON = up to 8
// controllers routed to the 2 ports x 4 slots. Extra pads (slots 2-7) reuse the P1
// button mapping. Also drives PadRouter's routing gate.
@@ -883,6 +883,11 @@ open class MainActivityRuntime : ComponentActivity() {
fun pauseForOverlay() {
if (vmStopInProgress)
return
// Keep the audio device alive across this brief in-game menu pause so Android
// doesn't reclaim the idle low-latency stream and force a ~1s rebuild on resume
// (the fast-forward-from-menu hitch, and audio dying after a paused menu).
// resume() clears the suppression; background/quit still pause audio normally.
NativeApp.setOutputPauseSuppressed(true)
NativeApp.pause()
}
@@ -1499,6 +1504,23 @@ open class MainActivityRuntime : ComponentActivity() {
copyAssetAll(applicationContext, "bios")
copyAssetAll(applicationContext, "resources")
// On an app UPDATE (versionCode changed), drop the regenerable GPU caches. Installing a
// new build over an old one keeps the compiled GS shader/pipeline cache under
// <dataRoot>/cache, and a cache baked by a different core build can render corrupt — the
// "scrambled PS2 logo" and post-update graphical glitches users currently fix by
// reinstalling clean (#376/#385). The cache is pure derived data (rebuilt on demand),
// never user content, so wiping it is always safe. Skipped on first install (no prior
// version recorded) — there is nothing stale to clear.
runCatching {
val prevVc = prefs.getInt("lastRunVersionCode", 0)
val curVc = BuildConfig.VERSION_CODE
if (prevVc != 0 && prevVc != curVc) {
File(assetCopyRoot(applicationContext), "cache").deleteRecursively()
android.util.Log.i("ARMSX2", "Update $prevVc -> $curVc: cleared GS shader/pipeline cache")
}
if (prevVc != curVc) prefs.edit { putInt("lastRunVersionCode", curVc) }
}
// Point the ANGLE EGL env vars at the bundled libs (or clear them) before the
// GS thread ever opens a GL context. Re-applied per launch below too.
applyAngleEnv(applicationContext)
@@ -1762,6 +1784,10 @@ open class MainActivityRuntime : ComponentActivity() {
startAutosaveIntervalJob()
// Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble).
NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled()
// Push the saved haptic strength + achievement-sound volume into their native gates before
// any rumble or unlock sound can fire (both default to 1.0 = as authored until set here).
ControllerMappings.syncHapticIntensity()
com.armsx2.ui.achievements.AchievementsViewModel.syncSoundVolume()
// Seed the pad-router's multitap gate before any in-game input is dispatched, so
// slot routing (2 vs 8 slots) is correct from the first controller event.
com.armsx2.input.PadRouter.multitapEnabled = ControllerMappings.multitapEnabled()
@@ -45,7 +45,20 @@ object InGameOverlay {
* choice (Full / Min / Off) isn't reset to the per-stat selection every launch. */
fun applyStoredOsdMode() {
ensureOsdLoaded()
applyOsdMode(osdMode.value)
// Custom = "the user's saved per-stat flags". At boot settingsState is NOT yet populated
// with THIS game's resolved settings, so reading it here applied stale/empty flags — which
// hid an enabled stat until a reset repopulated it (#385). Resolve the current game's
// settings ourselves. (A fresh install has every stat defaulting off, so this also cleanly
// shows nothing by default rather than whatever stale state was left in settingsState.)
if (osdMode.value == OsdMode.Custom) {
applyOsdFlags(
com.armsx2.config.ConfigStore.resolveForGame(
MainActivityRuntime.currentGame.value?.settingsKey,
),
)
} else {
applyOsdMode(osdMode.value)
}
}
/** Short label for [mode], shown by the hotkey toast and the menu selector. */
@@ -141,15 +154,7 @@ object InGameOverlay {
NativeApp.osdApplyFlags(true, false, false, true, false, false, false, false, false, false, false, false)
NativeApp.osdShowGpuStats(false)
}
OsdMode.Custom -> {
val s = settingsState.value
NativeApp.osdApplyFlags(
s.osdShowFps, s.osdShowVps, s.osdShowSpeed, s.osdShowCpu, s.osdShowGpu,
s.osdShowResolution, s.osdShowGsStats, s.osdShowFrameTimes, s.osdShowHardwareInfo,
s.osdShowVersion, s.osdShowSettings, s.osdShowInputs,
)
NativeApp.osdShowGpuStats(s.osdShowGpuStats)
}
OsdMode.Custom -> applyOsdFlags(settingsState.value)
OsdMode.Off -> {
NativeApp.osdApplyFlags(false, false, false, false, false, false, false, false, false, false, false, false)
NativeApp.osdShowGpuStats(false)
@@ -157,6 +162,19 @@ object InGameOverlay {
}
}
/** Push a Settings object's saved per-stat OSD selection to native the Custom mode. Split
* out so applyStoredOsdMode can feed it the boot-resolved settings (settingsState isn't ready
* yet at boot), while the live path feeds it settingsState. */
private fun applyOsdFlags(s: com.armsx2.config.Settings) {
osdMode.value = OsdMode.Custom
NativeApp.osdApplyFlags(
s.osdShowFps, s.osdShowVps, s.osdShowSpeed, s.osdShowCpu, s.osdShowGpu,
s.osdShowResolution, s.osdShowGsStats, s.osdShowFrameTimes, s.osdShowHardwareInfo,
s.osdShowVersion, s.osdShowSettings, s.osdShowInputs,
)
NativeApp.osdShowGpuStats(s.osdShowGpuStats)
}
fun editTouchLayout() {
com.armsx2.ui.touch.TouchControls.ensureLoaded()
com.armsx2.ui.touch.TouchControls.editMode.value = true
@@ -223,6 +223,19 @@ private fun AchievementAccount(
onRight = { if (!state.soundEffects) viewModel.setOption("soundEffects", true) },
),
)
// Volume of that unlock sound — only meaningful while the effect is on, so it slides
// in right under the toggle. App-side (MediaPlayer), no .wav editing needed.
if (state.soundEffects) {
com.armsx2.ui.settings.IntSliderRow(
label = str("ra.options.soundVolume"),
value = state.soundVolume,
min = 0,
max = 100,
description = str("ra.options.soundVolume.desc"),
valueFormatter = { if (it == 0) "Muted" else "${it}%" },
onChange = { viewModel.setSoundVolume(it) },
)
}
// Achievement modes. Toggling reloads the RA session (no VM reset); the native
// rc_client setters already exist, so these are plain option toggles.
SettingSwitchRow(

Some files were not shown because too many files have changed in this diff Show More