diff --git a/pcsx2/Achievements.cpp b/pcsx2/Achievements.cpp index 69a0830f8b..57c96960e7 100644 --- a/pcsx2/Achievements.cpp +++ b/pcsx2/Achievements.cpp @@ -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(user->score) + : static_cast(Host::GetIntSettingValue("Achievements", "LastScore", -1)); + const long long score_sc_val = user + ? static_cast(user->score_softcore) + : static_cast(Host::GetIntSettingValue("Achievements", "LastScoreSoftcore", -1)); out += ",\"score\":"; - out += std::to_string(user ? static_cast(user->score) : -1LL); + out += std::to_string(score_val); out += ",\"softcoreScore\":"; - out += std::to_string(user ? static_cast(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 diff --git a/pcsx2/Config.h b/pcsx2/Config.h index 770e13171e..265f70eb4b 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -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; diff --git a/pcsx2/GS/GS.cpp b/pcsx2/GS/GS.cpp index ad023c60df..d783df95db 100644 --- a/pcsx2/GS/GS.cpp +++ b/pcsx2/GS/GS.cpp @@ -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(); diff --git a/pcsx2/GS/Renderers/Common/GSDevice.cpp b/pcsx2/GS/Renderers/Common/GSDevice.cpp index 87db9e56da..2bd859f22e 100644 --- a/pcsx2/GS/Renderers/Common/GSDevice.cpp +++ b/pcsx2/GS/Renderers/Common/GSDevice.cpp @@ -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 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; diff --git a/pcsx2/GS/Renderers/HW/GSHwHack.h b/pcsx2/GS/Renderers/HW/GSHwHack.h index f0716a552e..505ffa060e 100644 --- a/pcsx2/GS/Renderers/HW/GSHwHack.h +++ b/pcsx2/GS/Renderers/HW/GSHwHack.h @@ -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); diff --git a/pcsx2/GS/Renderers/HW/GSRendererHW.cpp b/pcsx2/GS/Renderers/HW/GSRendererHW.cpp index 7bad376468..9e54b3e46d 100644 --- a/pcsx2/GS/Renderers/HW/GSRendererHW.cpp +++ b/pcsx2/GS/Renderers/HW/GSRendererHW.cpp @@ -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); } diff --git a/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp b/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp index f90b81e138..0cb49ce3f2 100644 --- a/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp +++ b/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp @@ -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) diff --git a/pcsx2/Host/AudioStream.cpp b/pcsx2/Host/AudioStream.cpp index ef1a8dee4b..972ae58310 100644 --- a/pcsx2/Host/AudioStream.cpp +++ b/pcsx2/Host/AudioStream.cpp @@ -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(std::clamp(wrap.EntryBitfield(section, "BufferMS", buffer_ms, DEFAULT_BUFFER_MS), 0, std::numeric_limits::max())); output_latency_ms = static_cast(std::clamp(wrap.EntryBitfield(section, "OutputLatencyMS", output_latency_ms, DEFAULT_OUTPUT_LATENCY_MS), 0, std::numeric_limits::max())); diff --git a/pcsx2/Host/AudioStreamTypes.h b/pcsx2/Host/AudioStreamTypes.h index ab410b5bd9..9cd1bcf818 100644 --- a/pcsx2/Host/AudioStreamTypes.h +++ b/pcsx2/Host/AudioStreamTypes.h @@ -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; diff --git a/pcsx2/Host/OboeAudioStream.cpp b/pcsx2/Host/OboeAudioStream.cpp index 235eb9cde3..f11384d4f4 100644 --- a/pcsx2/Host/OboeAudioStream.cpp +++ b/pcsx2/Host/OboeAudioStream.cpp @@ -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); diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index 9f3dc47cc7..f682513c5e 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -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) && diff --git a/pcsx2/SPU2/Mixer.cpp b/pcsx2/SPU2/Mixer.cpp index cd86ab1d28..6ba293ba65 100644 --- a/pcsx2/SPU2/Mixer.cpp +++ b/pcsx2/SPU2/Mixer.cpp @@ -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 diff --git a/platforms/android/app/src/main/cpp/native-lib.cpp b/platforms/android/app/src/main/cpp/native-lib.cpp index c0e4bf9601..4d750d9d31 100644 --- a/platforms/android/app/src/main/cpp/native-lib.cpp +++ b/platforms/android/app/src/main/cpp/native-lib.cpp @@ -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(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(points)); + s_secrets_settings_interface->SetIntValue("Achievements", "LastScoreSoftcore", static_cast(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 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(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( - 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 +// _.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; } diff --git a/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt b/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt index 5388d6e0c3..14216c1711 100644 --- a/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt +++ b/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt @@ -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() @@ -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, diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index 4cd04d9ecf..a0ac0000d7 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -317,6 +317,10 @@ val EN: Map = 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 = 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 = 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 = 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 = 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", diff --git a/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt b/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt index 3ad3ba621d..0791c27392 100644 --- a/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt +++ b/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt @@ -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. diff --git a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index 56f57cb36a..b6cbf2ee90 100644 --- a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -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 + // /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() diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt b/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt index 39eea51e53..12df7154a2 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt @@ -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 diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt index 7df3b570ef..598251c28f 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt @@ -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( diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsViewModel.kt index ef9ac2b93a..666e6b71e1 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsViewModel.kt @@ -57,6 +57,8 @@ data class AchievementsUiState( val unofficialTestMode: Boolean = false, // Display name of the user's custom achievement-unlock sound, or null for the default. val unlockSoundName: String? = null, + // Volume of the unlock sound effect, 0..100 % (app-side, applied in NativeApp.playSound). + val soundVolume: Int = 100, // Non-null while the hardcore confirm dialog is up; holds the target state. val pendingHardcore: Boolean? = null, val loading: Boolean = false, @@ -170,6 +172,7 @@ class AchievementsViewModel(application: Application) : AndroidViewModel(applica spectatorMode = root.optBoolean("spectatorMode", false), unofficialTestMode = root.optBoolean("unofficialTestMode", false), unlockSoundName = MainActivityRuntime.prefs.getString(UNLOCK_SOUND_PREF, null), + soundVolume = MainActivityRuntime.prefs.getInt(SOUND_VOLUME_PREF, 100), ) } @@ -209,6 +212,16 @@ class AchievementsViewModel(application: Application) : AndroidViewModel(applica state.value = state.value.copy(unlockSoundName = null) } + /** Volume for the unlock/info sound effect, 0..100 %. Applied app-side in + * NativeApp.playSound (MediaPlayer.setVolume) — the native core just hands it the .wav path, + * so this needs no [Achievements] setting. Takes effect on the next sound. */ + fun setSoundVolume(pct: Int) { + val clamped = pct.coerceIn(0, 100) + MainActivityRuntime.prefs.edit().putInt(SOUND_VOLUME_PREF, clamped).apply() + NativeApp.sSoundVolume = clamped / 100f + state.value = state.value.copy(soundVolume = clamped) + } + private fun queryDisplayName(context: android.content.Context, uri: android.net.Uri): String? = runCatching { context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { @@ -221,8 +234,17 @@ class AchievementsViewModel(application: Application) : AndroidViewModel(applica super.onCleared() } - private companion object { + companion object { const val UNLOCK_SOUND_PREF = "ra.unlockSoundName" + const val SOUND_VOLUME_PREF = "ra.soundVolume" + + /** Push the persisted unlock-sound volume into NativeApp at app start, before any + * achievement can unlock — otherwise the first sound of the session plays at full + * volume regardless of the slider until the RA screen is opened. */ + fun syncSoundVolume() { + NativeApp.sSoundVolume = + MainActivityRuntime.prefs.getInt(SOUND_VOLUME_PREF, 100).coerceIn(0, 100) / 100f + } } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt index bfd7b390e9..fabeb5f144 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt @@ -172,17 +172,29 @@ fun HomeScreen( val libraryBg = LibraryBackground.uri.value if (libraryBg == null) { // Default: the live PS3-XMB wave (XmbGlView — a GLES3 port of linkev's - // grid-displacement mesh, matching iOS). The bundled still sits behind it as a - // fallback for the rare case GL init fails. Custom backgrounds below override - // both, and clearing one returns here. - Image( - painter = painterResource(R.drawable.library_bg_xmb), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + // grid-displacement mesh, matching iOS). When GL can't init — older Mali without + // float-texture filtering, or any EGL failure — we fall back to a looping GIF + // instead of a frozen still. The bundled still is the cheap floor shown during GL + // startup (and, once the wave is up, sits hidden behind it), so capable devices + // never decode the heavy GIF. Custom backgrounds below override all of this. + var xmbGlState by remember { mutableStateOf(null) } // null=starting, true=up, false=failed + if (xmbGlState == false) { + AsyncImage( + model = ImageRequest.Builder(context).data(R.raw.library_fallback).build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Image( + painter = painterResource(R.drawable.library_bg_xmb), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } AndroidView( - factory = { XmbGlView(it) }, + factory = { XmbGlView(it).apply { onGlStatus = { ok -> xmbGlState = ok } } }, modifier = Modifier.fillMaxSize(), ) } else { diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/home/XmbGlView.kt b/platforms/android/app/src/main/java/com/armsx2/ui/home/XmbGlView.kt index c0435756dd..86287e72bb 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/home/XmbGlView.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/home/XmbGlView.kt @@ -37,16 +37,22 @@ import kotlin.math.sin class XmbGlView(context: Context) : TextureView(context), TextureView.SurfaceTextureListener { private var thread: RenderThread? = null + /** Reports whether the GL wave actually came up: true once the first frame presents, false if + * EGL/GLES3 init fails (e.g. older Mali without float-texture filtering). HomeScreen uses it + * to run an animated Compose backdrop only when the wave can't — no wasted work when it can. + * Always delivered on the main thread. */ + var onGlStatus: ((Boolean) -> Unit)? = null + init { surfaceTextureListener = this - // Non-opaque so that if EGL/GLES3 init ever fails and nothing renders, the still image - // layered behind this view in HomeScreen shows through instead of a black rectangle. When - // the GL path works it draws an opaque gradient every frame, fully covering that still. + // Non-opaque so that if EGL/GLES3 init ever fails and nothing renders, the layer behind + // this view in HomeScreen shows through instead of a black rectangle. When the GL path + // works it draws an opaque gradient every frame, fully covering that layer. isOpaque = false } override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) { - thread = RenderThread(st, w, h).also { it.start() } + thread = RenderThread(st, w, h) { ok -> post { onGlStatus?.invoke(ok) } }.also { it.start() } } override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) { @@ -65,6 +71,7 @@ class XmbGlView(context: Context) : TextureView(context), TextureView.SurfaceTex private val surfaceTexture: SurfaceTexture, private var width: Int, private var height: Int, + private val onStatus: (Boolean) -> Unit, ) : Thread("xmb-gl") { @Volatile private var running = true @Volatile private var sizeDirty = true @@ -91,15 +98,17 @@ class XmbGlView(context: Context) : TextureView(context), TextureView.SurfaceTex fun finish() { running = false; runCatching { join(500) } } override fun run() { - if (!initEgl()) return - runCatching { initGl() }.onFailure { Log.w(TAG, "GL init failed", it); teardown(); return } + if (!initEgl()) { onStatus(false); return } + runCatching { initGl() }.onFailure { Log.w(TAG, "GL init failed", it); onStatus(false); teardown(); return } startNanos = System.nanoTime() + var announced = false while (running) { val frameStart = System.nanoTime() if (sizeDirty) { GLES30.glViewport(0, 0, width, height); sizeDirty = false } val t = (frameStart - startNanos) / 1_000_000_000f runCatching { drawFrame(t) } if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) running = false + else if (!announced) { announced = true; onStatus(true) } // Cap to ~30 fps. The wave is slow, so 30 looks identical to 60 but roughly // halves GPU/CPU load — without this the loop ran flat-out at vsync (60) and // spun the RP6's fans up. Still more than a GIF (real mesh render vs a blit), diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AudioTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AudioTab.kt index 51de24abea..9100b3a476 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AudioTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AudioTab.kt @@ -99,5 +99,17 @@ fun AudioTab(state: MutableState) { s.spu2NeonReverb, description = str("audio.spu2Simd.description"), ) { apply(s.copy(spu2NeonReverb = it)) } + SettingsDivider() + ToggleRow( + str("audio.openSles.label"), + s.audioOpenSLES, + description = str("audio.openSles.description"), + ) { apply(s.copy(audioOpenSLES = it)) } + SettingsDivider() + ToggleRow( + str("audio.lightweight.label"), + s.spu2LightweightMix, + description = str("audio.lightweight.description"), + ) { apply(s.copy(spu2LightweightMix = it)) } } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt index bf059032ca..9fa120a2f4 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt @@ -255,6 +255,18 @@ fun PadTab(@Suppress("UNUSED_PARAMETER") state: MutableState) { ControllerMappings.setRumbleEnabled(it) refreshToken.intValue++ } + // Vibration strength: one multiplier over BOTH controller rumble and on-screen touch + // haptics (they share the motor path), so an over-eager motor can be tamed or a weak + // one boosted. 100% = as authored; 0% = off. + IntSliderRow( + label = str("pad.hapticStrength.label"), + value = ControllerMappings.hapticIntensity(), + min = 0, + max = 200, + description = str("pad.hapticStrength.description"), + valueFormatter = { if (it == 0) "Off" else "${it}%" }, + onChange = { ControllerMappings.setHapticIntensity(it); refreshToken.intValue++ }, + ) SettingsDivider() // PS2 Multitap: route up to 8 controllers (both ports become 4-slot taps). // The pref drives PadRouter's slot count + the boot-time native arming; when a diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt index 92fb30044c..2db354417c 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt @@ -268,42 +268,40 @@ fun PerformanceTab(state: MutableState) { SettingsDivider() CollapsibleSection(str("perf.gamedbFixes.title")) { HelpText(str("perf.gamedbFixes.help")) - ToggleRow(str("perf.fix.skipBios"), s.enableFastBoot) { apply(s.copy(enableFastBoot = it)) } - ToggleRow(str("perf.fix.gamedbFixes"), s.enableGameFixes) { apply(s.copy(enableGameFixes = it)) } - ToggleRow(str("perf.fix.skipMpeg"), s.gamefixSkipMpeg) { apply(s.copy(enableGameFixes = true, gamefixSkipMpeg = it)) } + ToggleRow(str("perf.fix.skipBios"), s.enableFastBoot, description = str("perf.fix.skipBios.desc")) { apply(s.copy(enableFastBoot = it)) } + ToggleRow(str("perf.fix.gamedbFixes"), s.enableGameFixes, description = str("perf.fix.gamedbFixes.desc")) { apply(s.copy(enableGameFixes = it)) } + ToggleRow(str("perf.fix.skipMpeg"), s.gamefixSkipMpeg, description = str("perf.fix.skipMpeg.desc")) { apply(s.copy(enableGameFixes = true, gamefixSkipMpeg = it)) } if (s.gamefixSkipMpeg) HelpText(str("perf.fix.skipMpeg.warning")) - ToggleRow(str("perf.fix.fmvSoftware"), s.gamefixSoftwareRendererFmv) { apply(s.copy(enableGameFixes = true, gamefixSoftwareRendererFmv = it)) } - ToggleRow(str("perf.fix.eeTiming"), s.gamefixEETiming) { apply(s.copy(enableGameFixes = true, gamefixEETiming = it)) } - ToggleRow(str("perf.fix.instantDma"), s.gamefixInstantDma) { apply(s.copy(enableGameFixes = true, gamefixInstantDma = it)) } - ToggleRow(str("perf.fix.blitFps"), s.gamefixBlitInternalFps) { apply(s.copy(enableGameFixes = true, gamefixBlitInternalFps = it)) } - ToggleRow(str("perf.fix.fpuMultiply"), s.gamefixFpuMul) { apply(s.copy(enableGameFixes = true, gamefixFpuMul = it)) } - ToggleRow(str("perf.fix.ophFlag"), s.gamefixOphFlag) { apply(s.copy(enableGameFixes = true, gamefixOphFlag = it)) } - ToggleRow(str("perf.fix.gifFifo"), s.gamefixGifFifo) { apply(s.copy(enableGameFixes = true, gamefixGifFifo = it)) } - ToggleRow(str("perf.fix.dmaBusy"), s.gamefixDmaBusy) { apply(s.copy(enableGameFixes = true, gamefixDmaBusy = it)) } - ToggleRow(str("perf.fix.vif1Stall"), s.gamefixVif1Stall) { apply(s.copy(enableGameFixes = true, gamefixVif1Stall = it)) } - ToggleRow(str("perf.fix.iBit"), s.gamefixIbit) { apply(s.copy(enableGameFixes = true, gamefixIbit = it)) } - ToggleRow(str("perf.fix.fullVu0Sync"), s.gamefixFullVu0Sync) { apply(s.copy(enableGameFixes = true, gamefixFullVu0Sync = it)) } - ToggleRow(str("perf.fix.vuAddSub"), s.gamefixVuAddSub) { apply(s.copy(enableGameFixes = true, gamefixVuAddSub = it)) } - ToggleRow(str("perf.fix.vuOverflow"), s.gamefixVuOverflow) { apply(s.copy(enableGameFixes = true, gamefixVuOverflow = it)) } - ToggleRow(str("perf.fix.extraXgkick"), s.gamefixXgkick) { apply(s.copy(enableGameFixes = true, gamefixXgkick = it)) } - ToggleRow(str("perf.fix.goemonTlb"), s.gamefixGoemonTlb) { apply(s.copy(enableGameFixes = true, gamefixGoemonTlb = it)) } - ToggleRow(str("perf.fix.vuSync"), s.gamefixVuSync) { apply(s.copy(enableGameFixes = true, gamefixVuSync = it)) } - HelpText(str("perf.gamedbFixes.legend")) + ToggleRow(str("perf.fix.fmvSoftware"), s.gamefixSoftwareRendererFmv, description = str("perf.fix.fmvSoftware.desc")) { apply(s.copy(enableGameFixes = true, gamefixSoftwareRendererFmv = it)) } + ToggleRow(str("perf.fix.eeTiming"), s.gamefixEETiming, description = str("perf.fix.eeTiming.desc")) { apply(s.copy(enableGameFixes = true, gamefixEETiming = it)) } + ToggleRow(str("perf.fix.instantDma"), s.gamefixInstantDma, description = str("perf.fix.instantDma.desc")) { apply(s.copy(enableGameFixes = true, gamefixInstantDma = it)) } + ToggleRow(str("perf.fix.blitFps"), s.gamefixBlitInternalFps, description = str("perf.fix.blitFps.desc")) { apply(s.copy(enableGameFixes = true, gamefixBlitInternalFps = it)) } + ToggleRow(str("perf.fix.fpuMultiply"), s.gamefixFpuMul, description = str("perf.fix.fpuMultiply.desc")) { apply(s.copy(enableGameFixes = true, gamefixFpuMul = it)) } + ToggleRow(str("perf.fix.ophFlag"), s.gamefixOphFlag, description = str("perf.fix.ophFlag.desc")) { apply(s.copy(enableGameFixes = true, gamefixOphFlag = it)) } + ToggleRow(str("perf.fix.gifFifo"), s.gamefixGifFifo, description = str("perf.fix.gifFifo.desc")) { apply(s.copy(enableGameFixes = true, gamefixGifFifo = it)) } + ToggleRow(str("perf.fix.dmaBusy"), s.gamefixDmaBusy, description = str("perf.fix.dmaBusy.desc")) { apply(s.copy(enableGameFixes = true, gamefixDmaBusy = it)) } + ToggleRow(str("perf.fix.vif1Stall"), s.gamefixVif1Stall, description = str("perf.fix.vif1Stall.desc")) { apply(s.copy(enableGameFixes = true, gamefixVif1Stall = it)) } + ToggleRow(str("perf.fix.iBit"), s.gamefixIbit, description = str("perf.fix.iBit.desc")) { apply(s.copy(enableGameFixes = true, gamefixIbit = it)) } + ToggleRow(str("perf.fix.fullVu0Sync"), s.gamefixFullVu0Sync, description = str("perf.fix.fullVu0Sync.desc")) { apply(s.copy(enableGameFixes = true, gamefixFullVu0Sync = it)) } + ToggleRow(str("perf.fix.vuAddSub"), s.gamefixVuAddSub, description = str("perf.fix.vuAddSub.desc")) { apply(s.copy(enableGameFixes = true, gamefixVuAddSub = it)) } + ToggleRow(str("perf.fix.vuOverflow"), s.gamefixVuOverflow, description = str("perf.fix.vuOverflow.desc")) { apply(s.copy(enableGameFixes = true, gamefixVuOverflow = it)) } + ToggleRow(str("perf.fix.extraXgkick"), s.gamefixXgkick, description = str("perf.fix.extraXgkick.desc")) { apply(s.copy(enableGameFixes = true, gamefixXgkick = it)) } + ToggleRow(str("perf.fix.goemonTlb"), s.gamefixGoemonTlb, description = str("perf.fix.goemonTlb.desc")) { apply(s.copy(enableGameFixes = true, gamefixGoemonTlb = it)) } + ToggleRow(str("perf.fix.vuSync"), s.gamefixVuSync, description = str("perf.fix.vuSync.desc")) { apply(s.copy(enableGameFixes = true, gamefixVuSync = it)) } } SettingsDivider() CollapsibleSection(str("perf.advancedSpeedhacks.title")) { Spacer(Modifier.height(8.dp)) - ToggleRow(str("perf.hack.mtvu"), s.mtvu) { apply(s.copy(mtvu = it)) } - ToggleRow(str("perf.hack.instantVu1"), s.vu1Instant) { apply(s.copy(vu1Instant = it)) } - ToggleRow(str("perf.hack.vuFlagHack"), s.vuFlagHack) { apply(s.copy(vuFlagHack = it)) } - ToggleRow(str("perf.hack.fastCdvd"), s.fastCDVD) { apply(s.copy(fastCDVD = it)) } - ToggleRow(str("perf.hack.intcStat"), s.intcStat) { apply(s.copy(intcStat = it)) } - ToggleRow(str("perf.hack.waitLoop"), s.waitLoop) { apply(s.copy(waitLoop = it)) } - ToggleRow(str("perf.hack.vuNeonFusions"), s.vuNeonFusions) { apply(s.copy(vuNeonFusions = it)) } - ToggleRow(str("perf.hack.skipVuStallSim"), s.vuSkipStallSim) { apply(s.copy(vuSkipStallSim = it)) } - ToggleRow(str("perf.hack.deferVuWrites"), s.vuDeferredWrites) { apply(s.copy(vuDeferredWrites = it)) } - ToggleRow(str("perf.hack.skipDupeFrames"), s.skipDuplicateFrames) { apply(s.copy(skipDuplicateFrames = it)) } - HelpText(str("perf.advancedSpeedhacks.legend")) + ToggleRow(str("perf.hack.mtvu"), s.mtvu, description = str("perf.hack.mtvu.desc")) { apply(s.copy(mtvu = it)) } + ToggleRow(str("perf.hack.instantVu1"), s.vu1Instant, description = str("perf.hack.instantVu1.desc")) { apply(s.copy(vu1Instant = it)) } + ToggleRow(str("perf.hack.vuFlagHack"), s.vuFlagHack, description = str("perf.hack.vuFlagHack.desc")) { apply(s.copy(vuFlagHack = it)) } + ToggleRow(str("perf.hack.fastCdvd"), s.fastCDVD, description = str("perf.hack.fastCdvd.desc")) { apply(s.copy(fastCDVD = it)) } + ToggleRow(str("perf.hack.intcStat"), s.intcStat, description = str("perf.hack.intcStat.desc")) { apply(s.copy(intcStat = it)) } + ToggleRow(str("perf.hack.waitLoop"), s.waitLoop, description = str("perf.hack.waitLoop.desc")) { apply(s.copy(waitLoop = it)) } + ToggleRow(str("perf.hack.vuNeonFusions"), s.vuNeonFusions, description = str("perf.hack.vuNeonFusions.desc")) { apply(s.copy(vuNeonFusions = it)) } + ToggleRow(str("perf.hack.skipVuStallSim"), s.vuSkipStallSim, description = str("perf.hack.skipVuStallSim.desc")) { apply(s.copy(vuSkipStallSim = it)) } + ToggleRow(str("perf.hack.deferVuWrites"), s.vuDeferredWrites, description = str("perf.hack.deferVuWrites.desc")) { apply(s.copy(vuDeferredWrites = it)) } + ToggleRow(str("perf.hack.skipDupeFrames"), s.skipDuplicateFrames, description = str("perf.hack.skipDupeFrames.desc")) { apply(s.copy(skipDuplicateFrames = it)) } } } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt index aff3d9f667..77f4e977e1 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt @@ -46,8 +46,9 @@ internal val SETTINGS_CATEGORY_FIELDS: Map> = map ), // AudioTab.kt SettingsCategory.Audio to listOf( - "audioBufferMs", "audioFastForwardVolume", "audioMuted", "audioOutputLatencyMs", - "audioSwapChannels", "audioTimeStretch", "audioVolume", "spu2NeonReverb", + "audioBufferMs", "audioFastForwardVolume", "audioMuted", "audioOpenSLES", + "audioOutputLatencyMs", "audioSwapChannels", "audioTimeStretch", "audioVolume", + "spu2LightweightMix", "spu2NeonReverb", ), // NetworkTab.kt SettingsCategory.Network to listOf( diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt index d39ed07e62..34c6bfdcfc 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt @@ -113,6 +113,8 @@ internal val SETTINGS_SEARCH_INDEX: List = listOf( SettingsSearchEntry("audio.synchronization.label", true, SettingsCategory.Audio), SettingsSearchEntry("audio.swapChannels.label", true, SettingsCategory.Audio), SettingsSearchEntry("audio.spu2Simd.label", true, SettingsCategory.Audio), + SettingsSearchEntry("audio.openSles.label", true, SettingsCategory.Audio), + SettingsSearchEntry("audio.lightweight.label", true, SettingsCategory.Audio), SettingsSearchEntry("audio.volume.label", true, SettingsCategory.Audio), SettingsSearchEntry("audio.buffer.label", true, SettingsCategory.Audio), SettingsSearchEntry("audio.outputLatency.label", true, SettingsCategory.Audio), diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsViewModel.kt index 219d69082a..1581088148 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsViewModel.kt @@ -2,11 +2,13 @@ package com.armsx2.ui.settingshub import android.app.Application import androidx.lifecycle.AndroidViewModel +import com.armsx2.EmuState import com.armsx2.GameInfo import com.armsx2.config.ConfigStore import com.armsx2.config.Settings import com.armsx2.config.SettingsScope import com.armsx2.navigation.SettingsCategory +import com.armsx2.runtime.MainActivityRuntime import com.armsx2.ui.InGameOverlay data class SettingsUiState( @@ -63,5 +65,31 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application settings.value = settings.value.resetCategory(category) ConfigStore.saveGlobal(settings.value) } + // Push the reset into the emulator's native config + per-game INI. Pruning the override + // JSON above only updates the on-screen values and the store; the native per-game INI + // (gamesettings/_.ini) still holds the pruned keys, and VMManager reloads the + // game layer from it on every commit/boot so the stale keys keep winning — which is why + // Reset "did nothing". Rewriting that INI clears the tab's keys while preserving the + // [Patches]/[Cheats] enable lists. + val running = MainActivityRuntime.nativeReady.value && + MainActivityRuntime.eState.value != EmuState.STOPPED + val gameSerial = serial?.takeIf { it.isNotBlank() } + runCatching { + when { + // In-game: re-apply live so the change shows immediately, then regenerate the + // running game's INI for the next boot (mirrors InGameOverlay.saveSettings). + gameSerial != null && running -> { + settings.value.applyTo() + ConfigStore.resolveForGame(gameSerial).writeGameSettingsIni(ConfigStore.loadGlobal()) + } + // From the library (no VM): the INI can't be reached through a running game, so + // rewrite it by serial. A no-op when the game never wrote one — then the pruned + // JSON alone already resolves to global at the next boot. + gameSerial != null -> ConfigStore.resolveForGame(gameSerial) + .writeGameSettingsIni(ConfigStore.loadGlobal(), gameSerial) + // Global scope with a game live: re-apply the reset globals to the base layer. + running -> settings.value.applyTo() + } + } } } diff --git a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java index 1cf0727109..fc692791be 100644 --- a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java +++ b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java @@ -292,6 +292,11 @@ public class NativeApp { * PCSX2's desktop UI). Stream: gameIniBeginWrite() once, gameIniPut() per * override key, gameIniCommitWrite() to save (or delete when empty). */ public static native boolean gameIniBeginWrite(); + /** VM-less variant of {@link #gameIniBeginWrite()}: targets a game's INI by serial (globbing + * gamesettings/<serial>_*.ini) when nothing is running, so a per-game Reset from the + * library can still clear a stale, in-game-written override file. Returns false when no such + * file exists — there is then nothing to rewrite and the caller should skip the put/commit. */ + public static native boolean gameIniBeginWriteForSerial(String serial); public static native void gameIniPut(String section, String key, String value); public static native boolean gameIniCommitWrite(); @@ -379,6 +384,11 @@ public class NativeApp { private static final java.util.Set sActiveSounds = java.util.Collections.synchronizedSet(new java.util.HashSet<>()); + /** Volume (0..1) for RA unlock / info / leaderboard-submit sounds. Set from Kotlin + * (AchievementsViewModel.setSoundVolume) so a slider tames the effect without editing the + * .wav. 1.0 = the sound as authored. */ + public static volatile float sSoundVolume = 1.0f; + public static void playSound(String path) { if (path == null || path.isEmpty()) return; // Cap concurrent players — a burst of simultaneous unlocks (combo/milestone) could @@ -389,7 +399,11 @@ public class NativeApp { try { mp = new android.media.MediaPlayer(); mp.setAudioAttributes(new android.media.AudioAttributes.Builder() - .setUsage(android.media.AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + // USAGE_GAME, not ASSISTANCE_SONIFICATION: the unlock jingle is game audio and + // must play on the media/game path. SONIFICATION is a UI/system-feedback usage + // that Do Not Disturb silences — which is why cheevo sounds went quiet with DND + // on. Game/media audio is exempt from DND, so this plays regardless. + .setUsage(android.media.AudioAttributes.USAGE_GAME) .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) .build()); mp.setDataSource(path); @@ -397,6 +411,7 @@ public class NativeApp { mp.setOnErrorListener((m, what, extra) -> { sActiveSounds.remove(m); try { m.release(); } catch (Throwable ignore) {} return true; }); sActiveSounds.add(mp); mp.prepare(); + mp.setVolume(sSoundVolume, sSoundVolume); mp.start(); } catch (Throwable t) { if (mp != null) { sActiveSounds.remove(mp); try { mp.release(); } catch (Throwable ignore) {} } @@ -449,9 +464,16 @@ public class NativeApp { } } + /** User-set haptic strength multiplier (0..2, default 1.0 = as authored). Scales EVERY + * vibration — controller rumble AND touch ticks both funnel through rumbleOne — so one + * "Vibration Strength" slider tames or boosts all of it. Set from Kotlin + * (ControllerMappings.setHapticIntensity) live and at app start. */ + public static volatile float sHapticScale = 1.0f; + /** @return true if [v] is a real, usable vibrator that was driven (or cancelled). */ private static boolean rumbleOne(Vibrator v, float intensity, int ms) { if (v == null || !v.hasVibrator()) return false; + intensity *= sHapticScale; if (intensity <= 0f) { try { v.cancel(); } catch (Throwable ignored) {} return true; @@ -616,6 +638,10 @@ public class NativeApp { public static native boolean runVMThread(String path); public static native void pause(); public static native void resume(); + // Keep the audio device alive across a menu/overlay pause (no reclaim, no + // resume rebuild). Set true right before pauseForOverlay's pause(); resume() + // clears it. See native setOutputPauseSuppressed / SPU2::SetOutputPauseSuppressed. + public static native void setOutputPauseSuppressed(boolean suppressed); public static native void shutdown(); public static native boolean hasActiveVM(); diff --git a/platforms/android/app/src/main/res/raw/library_fallback.gif b/platforms/android/app/src/main/res/raw/library_fallback.gif new file mode 100644 index 0000000000..9cec98ad46 Binary files /dev/null and b/platforms/android/app/src/main/res/raw/library_fallback.gif differ