From 5ea09213017dc2341cf7da2f2e6d739aca3db0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 2 Nov 2026 08:52:24 +0100 Subject: [PATCH 1/6] Ctrl: Let a press shorter than the sample interval be seen once A press and release that both land between two sceCtrl samples were lost. Now the next sample sees the button pressed. Idea from Azahar. Co-Authored-By: Claude Opus 5.5 --- Core/HLE/sceCtrl.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Core/HLE/sceCtrl.cpp b/Core/HLE/sceCtrl.cpp index c623d2b368..976edcc433 100644 --- a/Core/HLE/sceCtrl.cpp +++ b/Core/HLE/sceCtrl.cpp @@ -114,6 +114,10 @@ static u32 emuRapidFireInterval = 5; // no buttons and centered sticks, as impose does on a PSP. Host-side peeks still see the real state. static std::atomic gameInputBlocked{ false }; +// Buttons pressed since the last sample. A press and release that both land between two samples +// would otherwise never be seen by the game. Guarded by ctrlMutex. +static u32 ctrlUnsampledPresses = 0; + // These buttons are not affected by rapid fire (neither is analog.) const u32 CTRL_EMU_RAPIDFIRE_MASK = CTRL_UP | CTRL_DOWN | CTRL_LEFT | CTRL_RIGHT; @@ -122,7 +126,8 @@ static void __CtrlUpdateLatch() std::lock_guard guard(ctrlMutex); u64 t = CoreTiming::GetGlobalTimeUs(); - u32 buttons = ctrlCurrent.buttons; + u32 buttons = ctrlCurrent.buttons | ctrlUnsampledPresses; + ctrlUnsampledPresses = 0; if (emuRapidFire && emuRapidFireToggle) buttons &= CTRL_EMU_RAPIDFIRE_MASK; @@ -284,7 +289,8 @@ void __CtrlUpdateButtons(u32 bitsToSet, u32 bitsToClear) std::lock_guard guard(ctrlMutex); // There's no atomic operation for this, so mutex it is. - ctrlCurrent.buttons = (ctrlCurrent.buttons & ~bitsToClear) | bitsToSet; + ctrlUnsampledPresses |= bitsToSet & ~ctrlCurrent.buttons; + ctrlCurrent.buttons =(ctrlCurrent.buttons & ~bitsToClear) | bitsToSet; } void __CtrlSetAnalogXY(int stick, float x, float y) @@ -448,6 +454,7 @@ void __CtrlInit() { latch.btnRelease = 0xffffffff; gameInputBlocked = false; + ctrlUnsampledPresses = 0; memset(&ctrlCurrent, 0, sizeof(ctrlCurrent)); memset(ctrlCurrent.analog, CTRL_ANALOG_CENTER, sizeof(ctrlCurrent.analog)); analogEnabled = false; @@ -464,6 +471,9 @@ void __CtrlDoState(PointerWrap &p) if (!s) return; + // Host input from before the load shouldn't show up in the loaded state. + ctrlUnsampledPresses = 0; + Do(p, analogEnabled); Do(p, ctrlLatchBufs); Do(p, ctrlOldButtons); From 1d477fa2481be563a01cf4e8b60fb4e0d61ed2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 2 Nov 2026 08:52:24 +0100 Subject: [PATCH 2/6] Android: Request unbuffered dispatch of touch and joystick input Android batches touch moves and joystick axis events until the next UI vsync, up to a frame of latency. On API 30+, ask for them unbatched. Non-pointer sources only apply to the focused view, so the surface view now stays focused. Co-Authored-By: Claude Opus 5.5 --- .../org/ppsspp/ppsspp/NativeSurfaceView.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java b/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java index 06d518d5b1..8d5cc1a8a9 100644 --- a/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java +++ b/android/src/org/ppsspp/ppsspp/NativeSurfaceView.java @@ -7,8 +7,10 @@ package org.ppsspp.ppsspp; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; +import android.os.Build; import android.os.SystemClock; import android.text.InputType; +import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.SurfaceView; @@ -28,6 +30,21 @@ public class NativeSurfaceView extends SurfaceView { super(context); } + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Without this, touch moves and joystick axis events are batched until the next UI vsync, + // adding up to a frame of latency before the emulator sees them. Non-pointer sources only + // apply while this view is focused, so keep it focusable. + setFocusable(true); + setFocusableInTouchMode(true); + setDefaultFocusHighlightEnabled(false); + requestFocus(); + requestUnbufferedDispatch(InputDevice.SOURCE_CLASS_POINTER | InputDevice.SOURCE_CLASS_JOYSTICK); + } + } + @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(final MotionEvent ev) { @@ -55,9 +72,12 @@ public class NativeSurfaceView extends SurfaceView { } else { imm.hideSoftInputFromWindow(getWindowToken(), 0); imm.restartInput(this); - clearFocus(); - setFocusableInTouchMode(false); - setFocusable(false); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + // On R+ we stay focused for unbuffered dispatch, see onAttachedToWindow. + clearFocus(); + setFocusableInTouchMode(false); + setFocusable(false); + } } } From 445ecb46dce41fd4f6e88b786f1fbd597c98236f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 2 Nov 2026 09:04:26 +0100 Subject: [PATCH 3/6] Android: Let Oboe pick the callback size, grow the buffer on underruns setFramesPerDataCallback forced callbacks of PROPERTY_OUTPUT_FRAMES_PER_BUFFER frames, which made Oboe add a block adapter holding back an extra buffer whenever that doesn't match the burst. On a Galaxy S21 Ultra the MMAP stream has a 96 frame burst against a 192 frame property. NativeMix takes any size. The buffer was fixed at two bursts, 4 ms there, with nothing to recover from underruns. Use Oboe's LatencyTuner, which starts at two bursts and adds one per underrun, and log the underrun count when the stream closes. Also take the lock when clearing the error and info strings. Co-Authored-By: Claude Opus 5.5 --- android/jni/AndroidAudio.cpp | 4 ++-- android/jni/OboeContext.cpp | 36 +++++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/android/jni/AndroidAudio.cpp b/android/jni/AndroidAudio.cpp index 27f0327054..5eaf930c7e 100644 --- a/android/jni/AndroidAudio.cpp +++ b/android/jni/AndroidAudio.cpp @@ -25,8 +25,8 @@ AudioContext::AudioContext(AndroidAudioCallback cb, int _FramesPerBuffer, int _S framesPerBuffer = 4096; sampleRate = _SampleRate; - g_error = ""; - g_info = ""; + SetErrorString(""); + SetInfoString(""); } void AudioContext::SetErrorString(const std::string &error) { diff --git a/android/jni/OboeContext.cpp b/android/jni/OboeContext.cpp index 15815ce064..b26c8fe730 100644 --- a/android/jni/OboeContext.cpp +++ b/android/jni/OboeContext.cpp @@ -15,8 +15,8 @@ // callback on a thread of its own that can outlive the OboeContext. class OboeContext::Streams : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback, public std::enable_shared_from_this { public: - Streams(AndroidAudioCallback cb, int framesPerBuffer, int sampleRate) - : audioCallback_(cb), framesPerBuffer_(framesPerBuffer), sampleRate_(sampleRate) { + Streams(AndroidAudioCallback cb, int sampleRate) + : audioCallback_(cb), sampleRate_(sampleRate) { // The setting isn't offered before Android 8.1, where AAudio isn't stable. if (g_Config.iAndroidAudioApi == (int)AndroidAudioApi::AAUDIO && oboe::AudioStreamBuilder::isAAudioRecommended()) { audioApi_ = oboe::AudioApi::AAudio; @@ -49,12 +49,16 @@ public: void Shutdown() { std::lock_guard guard(mutex_); shutdown_ = true; - CloseStream(output_); + CloseOutput(); CloseStream(input_); } oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData, int32_t numFrames) override { if (stream->getDirection() == oboe::Direction::Output) { + // Grows the buffer a burst at a time when we underrun. + if (outputTuner_) { + outputTuner_->tune(); + } audioCallback_((short *)audioData, numFrames, stream->getSampleRate(), nullptr); } else { // This is the audio thread, so just buffer it. The CPU thread picks it up. @@ -77,6 +81,7 @@ public: } WARN_LOG(Log::Audio, "Oboe: %s stream closed with error: %s", isOutput ? "output" : "input", oboe::convertToText(error)); if (isOutput) { + outputTuner_.reset(); output_.reset(); } else { input_.reset(); @@ -107,7 +112,9 @@ private: ->setChannelConversionAllowed(true) ->setSampleRate(sampleRate_) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium) - ->setFramesPerDataCallback(framesPerBuffer_) + // No setFramesPerDataCallback: NativeMix takes any size, and forcing one makes Oboe add a + // block adapter, which holds back an extra buffer when it doesn't match the burst (MMAP + // streams often have a smaller burst than PROPERTY_OUTPUT_FRAMES_PER_BUFFER). ->setDataCallback(this) ->setErrorCallback(shared_from_this()); oboe::Result result = builder.openStream(output_); @@ -115,11 +122,11 @@ private: output_.reset(); return Fail("Failed to open output stream", result); } - // Double buffering, the same latency as the old OpenSL path. - output_->setBufferSizeInFrames(output_->getFramesPerBurst() * 2); + // Starts at two bursts. + outputTuner_ = std::make_unique(*output_); result = output_->requestStart(); if (result != oboe::Result::OK) { - CloseStream(output_); + CloseOutput(); return Fail("Failed to start output stream", result); } char info[256]; @@ -161,6 +168,16 @@ private: return true; } + void CloseOutput() { + if (output_) { + oboe::ResultWithValue xruns = output_->getXRunCount(); + INFO_LOG(Log::Audio, "Oboe: Closing output stream, buffer %d, underruns %d", output_->getBufferSizeInFrames(), xruns ? xruns.value() : -1); + } + // Stopped first, so the callback is done with the tuner. + CloseStream(output_); + outputTuner_.reset(); + } + static void CloseStream(std::shared_ptr &stream) { if (stream) { stream->stop(); @@ -176,13 +193,14 @@ private: } AndroidAudioCallback audioCallback_; - int framesPerBuffer_; int sampleRate_; oboe::AudioApi audioApi_ = oboe::AudioApi::OpenSLES; std::mutex mutex_; bool shutdown_ = false; std::shared_ptr output_; + // Only touched by the audio callback while output_ is running. + std::unique_ptr outputTuner_; std::shared_ptr input_; int inputSampleRate_ = 0; }; @@ -192,7 +210,7 @@ OboeContext::OboeContext(AndroidAudioCallback cb, int _FramesPerBuffer, int _Sam // Only used by the OpenSL ES fallback, which can't query the device's native values itself. oboe::DefaultStreamValues::SampleRate = sampleRate; oboe::DefaultStreamValues::FramesPerBurst = framesPerBuffer; - streams_ = std::make_shared(cb, framesPerBuffer, sampleRate); + streams_ = std::make_shared(cb, sampleRate); } bool OboeContext::Init() { From 9add5a78f378d640cabac19f315981e03c5160e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 2 Nov 2026 09:04:26 +0100 Subject: [PATCH 4/6] Android: Report each frame's work to an ADPF performance hint session Emulation does a burst of work each frame and then waits, so the scheduler sees a mostly idle thread and moves it to slow cores at low clocks. On Android 13+, report the frame thread's CPU time per frame against a target of half a frame. CPU time leaves out the frame limiter's sleep and any blocking on vsync or the GPU. Idea from Azahar. Does nothing on devices without hint session support. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 + android/jni/Android.mk | 1 + android/jni/PerformanceHint.cpp | 98 +++++++++++++++++++++++++++++++++ android/jni/PerformanceHint.h | 39 +++++++++++++ android/jni/app-android.cpp | 6 +- 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 android/jni/PerformanceHint.cpp create mode 100644 android/jni/PerformanceHint.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6120c3ee9e..4153c11eff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -896,6 +896,8 @@ if(ANDROID) android/jni/AndroidEGLGraphicsContext.h android/jni/OboeContext.cpp android/jni/OboeContext.h + android/jni/PerformanceHint.cpp + android/jni/PerformanceHint.h ) # No target elseif(IOS AND NOT LIBRETRO) diff --git a/android/jni/Android.mk b/android/jni/Android.mk index a97bfbed1a..c149e59b19 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -1049,6 +1049,7 @@ LOCAL_SRC_FILES := \ $(SRC)/android/jni/AndroidAudio.cpp \ $(SRC)/android/jni/AndroidEGLGraphicsContext.cpp \ $(SRC)/android/jni/OboeContext.cpp \ + $(SRC)/android/jni/PerformanceHint.cpp \ $(SRC)/UI/ImDebugger/ImDebugger.cpp \ $(SRC)/UI/ImDebugger/ImGe.cpp \ $(SRC)/UI/ImDebugger/ImConsole.cpp \ diff --git a/android/jni/PerformanceHint.cpp b/android/jni/PerformanceHint.cpp new file mode 100644 index 0000000000..4dc3e2d85a --- /dev/null +++ b/android/jni/PerformanceHint.cpp @@ -0,0 +1,98 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include +#include +#include + +#include "Common/Log.h" +#include "android/jni/PerformanceHint.h" + +struct APerformanceHintManager; + +namespace { + +// Half a frame. With CPU time as the measure, this leaves the other half as headroom, so the +// scheduler keeps us on a core fast enough to finish well before the frame is due. +constexpr int64_t TARGET_WORK_NS = 1000000000LL / 60 / 2; + +// Looked up at runtime, since we build against API 21 and these are API 33. +struct HintApi { + APerformanceHintManager *(*getManager)(); + APerformanceHintSession *(*createSession)(APerformanceHintManager *, const int32_t *, size_t, int64_t); + int (*reportActualWorkDuration)(APerformanceHintSession *, int64_t); + void (*closeSession)(APerformanceHintSession *); + + bool Load() { + void *lib = dlopen("libandroid.so", RTLD_NOW); + if (!lib) { + return false; + } + getManager = (decltype(getManager))dlsym(lib, "APerformanceHint_getManager"); + createSession = (decltype(createSession))dlsym(lib, "APerformanceHint_createSession"); + reportActualWorkDuration = (decltype(reportActualWorkDuration))dlsym(lib, "APerformanceHint_reportActualWorkDuration"); + closeSession = (decltype(closeSession))dlsym(lib, "APerformanceHint_closeSession"); + return getManager && createSession && reportActualWorkDuration && closeSession; + } +}; + +const HintApi *GetApi() { + static HintApi api{}; + static const bool loaded = api.Load(); + return loaded ? &api : nullptr; +} + +// Time spent running on a CPU, so sleeping in the frame limiter or blocking on vsync or the GPU +// doesn't count as work. +int64_t ThreadCpuTimeNs() { + timespec ts; + if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) != 0) { + return -1; + } + return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec; +} + +} // namespace + +PerformanceHintSession::~PerformanceHintSession() { + if (session_) { + GetApi()->closeSession(session_); + } +} + +void PerformanceHintSession::ReportFrame() { + if (failed_) { + return; + } + if (!session_) { + const HintApi *api = GetApi(); + APerformanceHintManager *manager = api ? api->getManager() : nullptr; + const int32_t tid = gettid(); + session_ = manager ? api->createSession(manager, &tid, 1, TARGET_WORK_NS) : nullptr; + if (!session_) { + INFO_LOG(Log::System, "Performance hint sessions not available"); + failed_ = true; + return; + } + INFO_LOG(Log::System, "Performance hint session created for thread %d, target %d us", tid, (int)(TARGET_WORK_NS / 1000)); + } + const int64_t now = ThreadCpuTimeNs(); + if (lastCpuTimeNs_ >= 0 && now > lastCpuTimeNs_) { + GetApi()->reportActualWorkDuration(session_, now - lastCpuTimeNs_); + } + lastCpuTimeNs_ = now; +} diff --git a/android/jni/PerformanceHint.h b/android/jni/PerformanceHint.h new file mode 100644 index 0000000000..2bd738fe46 --- /dev/null +++ b/android/jni/PerformanceHint.h @@ -0,0 +1,39 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include + +struct APerformanceHintSession; + +// An ADPF performance hint session (Android 13+) for the thread that runs the frame loop. +// Emulation does a burst of work each frame and then waits, so the scheduler sees a mostly idle +// thread and moves it to a slow core at low clocks, where the next burst then runs. Reporting each +// frame's work against a target tells it what we actually need. Does nothing where unsupported. +class PerformanceHintSession { +public: + ~PerformanceHintSession(); + + // Call once per frame, always from the same thread. The session is created on the first call. + void ReportFrame(); + +private: + APerformanceHintSession *session_ = nullptr; + bool failed_ = false; + int64_t lastCpuTimeNs_ = -1; +}; diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 4778870450..19b55e9a33 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -113,6 +113,7 @@ struct JNIEnv {}; #include "UI/GameInfoCache.h" #include "app-android.h" +#include "android/jni/PerformanceHint.h" AndroidAudioState *g_audioState; @@ -1781,10 +1782,13 @@ static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContex } renderer_inited = true; - const auto frame = [](GraphicsContext *graphicsContext) { + // The frame callback runs on the emu thread, which with OpenGL isn't this one. + PerformanceHintSession perfHint; + const auto frame = [&perfHint](GraphicsContext *graphicsContext) { ApplyPendingResize(); NativeFrame(graphicsContext); ProcessFrameCommands(); + perfHint.ReportFrame(); return !exitRenderLoop; }; // Both backends share one lifecycle now, which is the point of all this: RunGraphicsLoop picks From 32b67c0c553ab3726b18eb702145e7375673a4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 2 Nov 2026 09:24:03 +0100 Subject: [PATCH 5/6] CMake: Declare git-version.cpp as a byproduct of the GitVersion step Ninja only knew git-version.cpp from its timestamp at the start of the build, so when the GitVersion step rewrote it, the object wasn't rebuilt until some later build happened to notice. An Android APK built right after a commit could carry the previous commit's version string. Co-Authored-By: Claude Opus 5.5 --- Core/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 0877137a46..cec3d2b2b2 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -805,7 +805,11 @@ if (WIN32) set(WIN_VERSION_CMD COMMAND ${CMAKE_SOURCE_DIR}/Windows/git-version-gen.cmd PPSSPPWindows) endif() +# BYPRODUCTS tells Ninja that this writes git-version.cpp, so it checks the file again after the +# command has run. Otherwise it only sees the timestamp from before the build, and the object is +# recompiled a build late, if at all. The file is only rewritten when the version changes. add_custom_command(OUTPUT something_that_never_exists + BYPRODUCTS ${CMAKE_BINARY_DIR}/git-version.cpp COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR=${CMAKE_SOURCE_DIR} -DOUTPUT_DIR=${CMAKE_BINARY_DIR} -P ${CMAKE_SOURCE_DIR}/git-version.cmake From f1a76fde80aa4c72f0fa9b980d2c4ccf62c2c67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 2 Nov 2026 09:24:33 +0100 Subject: [PATCH 6/6] Docs: The Android version string no longer lags behind Co-Authored-By: Claude Opus 5.5 --- docs/android-device-testing.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/android-device-testing.md b/docs/android-device-testing.md index 73862110cf..811c60247d 100644 --- a/docs/android-device-testing.md +++ b/docs/android-device-testing.md @@ -26,11 +26,10 @@ Verify after every install, not just when something looks odd: A second, independent check is worth it when a result is surprising: grep the log for a symbol that exists only on the branch under test (a renamed class or log tag, say). -The version string itself can also lie. The Android build regenerates `git-version.cpp` but does not -always recompile it, so the APK can carry the previous build's string while containing current code. -When the version matters: - - find android/.cxx -name git-version.cpp.o -delete +The version string in the app's UI used to lie as well: Ninja regenerated `git-version.cpp` but didn't +recompile it, so the APK could carry an older commit's string. That's fixed (the file is now declared +as a `BYPRODUCTS` of the generating step in `Core/CMakeLists.txt`), so on a build directory that has +been reconfigured since, the string matches the commit the APK was built from. ## Forcing an activity recreate without touching the device