From fd86f3009fa95407e94faca95211eaf3f5217516 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 27 Feb 2026 00:30:01 -0500 Subject: [PATCH] fix: persist paused image on resume, persist pause through lifecycle, add visual indicator of pause state, with resume on press --- .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 4 + .../yuzu_emu/fragments/EmulationFragment.kt | 133 ++++++++++++++---- src/android/app/src/main/jni/native.cpp | 41 +++++- .../src/main/res/drawable/circle_white.xml | 4 + .../main/res/layout/fragment_emulation.xml | 20 +++ 5 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 src/android/app/src/main/res/drawable/circle_white.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index e49f466462..1f0acf2835 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -152,6 +152,10 @@ object NativeLibrary { external fun surfaceDestroyed() + external fun getAppletCaptureBuffer(): ByteArray + external fun getAppletCaptureWidth(): Int + external fun getAppletCaptureHeight(): Int + /** * Unpauses emulation from a paused state. */ diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 3be8098a66..dd14017713 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -15,6 +15,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.graphics.Bitmap import android.net.Uri import android.os.BatteryManager import android.os.BatteryManager.* @@ -97,6 +98,7 @@ import org.yuzu.yuzu_emu.utils.collect import org.yuzu.yuzu_emu.utils.CustomSettingsHandler import java.io.ByteArrayOutputStream import java.io.File +import java.nio.ByteBuffer import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.or @@ -141,6 +143,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var wasInputOverlayAutoHidden = false private var overlayTouchActive = false + private var pausedFrameBitmap: Bitmap? = null var shouldUseCustom = false private var isQuickSettingsMenuOpen = false @@ -703,6 +706,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { binding.inGameMenu.menu.findItem(R.id.menu_quick_settings)?.isVisible = BooleanSetting.ENABLE_QUICK_SETTINGS.getBoolean() + binding.pausedIcon.setOnClickListener { + if (this::emulationState.isInitialized && emulationState.isPaused) { + resumeEmulationFromUi() + } + } + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { val lockMode = IntSetting.LOCK_DRAWER.getInt() val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { @@ -728,11 +737,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { when (it.itemId) { R.id.menu_pause_emulation -> { if (emulationState.isPaused) { - emulationState.run(false, userInitiatedResume = true) - updatePauseMenuEntry(false) + resumeEmulationFromUi() } else { - emulationState.pause(userInitiated = true) + emulationState.pause() updatePauseMenuEntry(true) + capturePausedFrameFromCore() + updatePausedFrameVisibility() } binding.inGameMenu.requestFocus() true @@ -826,6 +836,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } R.id.menu_exit -> { + clearPausedFrame() emulationState.stop() NativeConfig.reloadGlobalConfig() emulationViewModel.setIsEmulationStopping(true) @@ -1197,6 +1208,74 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + private fun capturePausedFrameFromCore() { + lifecycleScope.launch(Dispatchers.Default) { + val frameData = NativeLibrary.getAppletCaptureBuffer() + val width = NativeLibrary.getAppletCaptureWidth() + val height = NativeLibrary.getAppletCaptureHeight() + if (frameData.isEmpty() || width <= 0 || height <= 0) { + Log.warning( + "[EmulationFragment] Paused frame capture returned empty/invalid data. " + + "size=${frameData.size}, width=$width, height=$height" + ) + return@launch + } + + val expectedSize = width * height * 4 + if (frameData.size < expectedSize) { + Log.warning( + "[EmulationFragment] Paused frame buffer smaller than expected. " + + "size=${frameData.size}, expected=$expectedSize" + ) + return@launch + } + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(frameData, 0, expectedSize)) + + withContext(Dispatchers.Main) { + pausedFrameBitmap?.recycle() + pausedFrameBitmap = bitmap + updatePausedFrameVisibility() + } + } + } + + private fun updatePausedFrameVisibility() { + val b = _binding ?: return + val shouldShowPaused = this::emulationState.isInitialized && emulationState.isPaused + b.pausedIcon.setVisible(shouldShowPaused) + + if (!shouldShowPaused) { + b.pausedFrameImage.setVisible(false) + b.pausedFrameImage.setImageDrawable(null) + return + } + + val bitmap = pausedFrameBitmap ?: run { + b.pausedFrameImage.setVisible(false) + b.pausedFrameImage.setImageDrawable(null) + return + } + b.pausedFrameImage.setImageBitmap(bitmap) + b.pausedFrameImage.setVisible(true) + } + + private fun resumeEmulationFromUi() { + clearPausedFrame() + emulationState.run(false, explicitResume = true) + updatePauseMenuEntry(false) + updatePausedFrameVisibility() + } + + private fun clearPausedFrame() { + val b = _binding + b?.pausedFrameImage?.setVisible(false) + b?.pausedFrameImage?.setImageDrawable(null) + pausedFrameBitmap?.recycle() + pausedFrameBitmap = null + } + private fun handleLoadAmiiboSelection(): Boolean { val binding = _binding ?: return true @@ -1292,7 +1371,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { emulationState.pause() updatePauseMenuEntry(true) + capturePausedFrameFromCore() } + updatePausedFrameVisibility() } super.onPause() } @@ -1301,6 +1382,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { super.onDestroyView() amiiboLoadJob?.cancel() amiiboLoadJob = null + clearPausedFrame() _binding?.surfaceInputOverlay?.touchEventListener = null _binding = null isAmiiboPickerOpen = false @@ -1321,6 +1403,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { b.inGameMenu.post { if (!this::emulationState.isInitialized || _binding == null) return@post updatePauseMenuEntry(emulationState.isPaused) + updatePausedFrameVisibility() } } @@ -1760,6 +1843,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // Only update surface reference, don't trigger state changes emulationState.updateSurfaceReference(holder.surface) } + updatePausedFrameVisibility() } override fun surfaceDestroyed(holder: SurfaceHolder) { @@ -2030,7 +2114,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private val emulationCanStart: () -> Boolean ) { private var state: State - private var userPaused = false private var surface: Surface? = null lateinit var emulationThread: Thread @@ -2062,7 +2145,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } @Synchronized - fun pause(userInitiated: Boolean = false) { + fun pause() { if (state != State.PAUSED) { Log.debug("[EmulationFragment] Pausing emulation.") @@ -2070,11 +2153,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { NativeLibrary.playTimeManagerStop() state = State.PAUSED - userPaused = userInitiated } else { - if (userInitiated) { - userPaused = true - } Log.warning("[EmulationFragment] Pause called while already paused.") } } @@ -2083,16 +2162,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { fun run( isActivityRecreated: Boolean, programIndex: Int = 0, - userInitiatedResume: Boolean = false + explicitResume: Boolean = false ) { - if (userInitiatedResume) { - userPaused = false - } - if (isActivityRecreated) { if (NativeLibrary.isRunning()) { state = State.PAUSED - userPaused = false } } else { Log.debug("[EmulationFragment] activity resumed or fresh start") @@ -2100,7 +2174,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // If the surface is set, run now. Otherwise, wait for it to get set. if (surface != null) { - runWithValidSurface(programIndex) + runWithValidSurface(programIndex, explicitResume) } } @@ -2125,7 +2199,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { @Synchronized fun updateSurface() { - if (surface != null) { + if (surface != null && state == State.RUNNING) { NativeLibrary.surfaceChanged(surface) } } @@ -2141,35 +2215,40 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { @Synchronized fun clearSurface() { if (surface == null) { - Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + Log.debug("[EmulationFragment] clearSurface called, but surface already null.") } else { + NativeLibrary.surfaceDestroyed() surface = null Log.debug("[EmulationFragment] Surface destroyed.") when (state) { State.RUNNING -> { state = State.PAUSED - userPaused = false } - State.PAUSED -> Log.warning( + State.PAUSED -> Log.debug( "[EmulationFragment] Surface cleared while emulation paused." ) - else -> Log.warning( + else -> Log.debug( "[EmulationFragment] Surface cleared while emulation stopped." ) } } } - private fun runWithValidSurface(programIndex: Int = 0) { - NativeLibrary.surfaceChanged(surface) + private fun runWithValidSurface(programIndex: Int = 0, explicitResume: Boolean = false) { if (!emulationCanStart.invoke()) { return } + val currentSurface = surface + if (currentSurface == null || !currentSurface.isValid) { + Log.debug("[EmulationFragment] runWithValidSurface called with invalid surface.") + return + } when (state) { State.STOPPED -> { + NativeLibrary.surfaceChanged(currentSurface) emulationThread = Thread({ Log.debug("[EmulationFragment] Starting emulation thread.") NativeLibrary.run(gamePath, programIndex, true) @@ -2178,11 +2257,15 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } State.PAUSED -> { - if (userPaused) { - Log.debug("[EmulationFragment] Surface restored while user paused.") + if (!explicitResume) { + Log.debug( + "[EmulationFragment] Surface restored while emulation paused; " + + "deferring native surface update until explicit resume." + ) return } + NativeLibrary.surfaceChanged(currentSurface) Log.debug("[EmulationFragment] Resuming emulation.") NativeLibrary.unpauseEmulation() NativeLibrary.playTimeManagerStart() diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 2e50bb1069..c429f4a1e4 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -89,6 +89,8 @@ #include "jni/native.h" #include "video_core/renderer_base.h" #include "video_core/renderer_vulkan/renderer_vulkan.h" +#include "video_core/capture.h" +#include "video_core/textures/decoders.h" #include "video_core/vulkan_common/vulkan_instance.h" #include "video_core/vulkan_common/vulkan_surface.h" #include "video_core/shader_notify.h" @@ -780,9 +782,10 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceChanged(JNIEnv* env, jobject i } void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceDestroyed(JNIEnv* env, jobject instance) { - ANativeWindow_release(EmulationSession::GetInstance().NativeWindow()); + if (auto* native_window = EmulationSession::GetInstance().NativeWindow(); native_window) { + ANativeWindow_release(native_window); + } EmulationSession::GetInstance().SetNativeWindow(nullptr); - EmulationSession::GetInstance().SurfaceChanged(); } void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject instance, @@ -969,6 +972,40 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isPaused(JNIEnv* env, jclass claz return static_cast(EmulationSession::GetInstance().IsPaused()); } +jbyteArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureBuffer(JNIEnv* env, jclass clazz) { + using namespace VideoCore::Capture; + + if (!EmulationSession::GetInstance().IsRunning()) { + return env->NewByteArray(0); + } + + const auto tiled = EmulationSession::GetInstance().System().GPU().GetAppletCaptureBuffer(); + if (tiled.size() < TiledSize) { + return env->NewByteArray(0); + } + + std::vector linear(LinearWidth * LinearHeight * BytesPerPixel); + Tegra::Texture::UnswizzleTexture(linear, tiled, BytesPerPixel, LinearWidth, LinearHeight, + LinearDepth, BlockHeight, BlockDepth); + + auto buffer = env->NewByteArray(static_cast(linear.size())); + if (!buffer) { + return env->NewByteArray(0); + } + + env->SetByteArrayRegion(buffer, 0, static_cast(linear.size()), + reinterpret_cast(linear.data())); + return buffer; +} + +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureWidth(JNIEnv* env, jclass clazz) { + return static_cast(VideoCore::Capture::LinearWidth); +} + +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureHeight(JNIEnv* env, jclass clazz) { + return static_cast(VideoCore::Capture::LinearHeight); +} + void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass clazz, jboolean reload) { // Initialize the emulated system. diff --git a/src/android/app/src/main/res/drawable/circle_white.xml b/src/android/app/src/main/res/drawable/circle_white.xml new file mode 100644 index 0000000000..c94e68679a --- /dev/null +++ b/src/android/app/src/main/res/drawable/circle_white.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index 7f5f039d5e..1913be0cf0 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -142,6 +142,26 @@ android:layout_height="match_parent" android:fitsSystemWindows="false"> + + + +