From c3afd2fabd329b9205e6106b7ae5e641e327f15e Mon Sep 17 00:00:00 2001 From: lizzie Date: Tue, 31 Mar 2026 20:15:14 +0200 Subject: [PATCH 1/8] [hle] fetch manager once in cmif wrapper (#3796) shouldn't need to fetch twice or thrice per response :) Signed-off-by: lizzie Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3796 Reviewed-by: Maufeat Reviewed-by: MaranBr Co-authored-by: lizzie Co-committed-by: lizzie --- src/core/hle/service/cmif_serialization.h | 22 +++++++++++----------- src/core/hle/service/ipc_helpers.h | 22 +++++++++++----------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/core/hle/service/cmif_serialization.h b/src/core/hle/service/cmif_serialization.h index 4d32c6cd6b..75461cc6be 100644 --- a/src/core/hle/service/cmif_serialization.h +++ b/src/core/hle/service/cmif_serialization.h @@ -438,20 +438,20 @@ void WriteOutArgument(bool is_domain, CallArguments& args, u8* raw_data, HLERequ template void CmifReplyWrapImpl(HLERequestContext& ctx, T& t, Result (T::*f)(A...)) { + const auto mgr = ctx.GetManager().get(); // Verify domain state. if constexpr (!Domain) { - const auto _mgr = ctx.GetManager(); - const bool _is_domain = _mgr ? _mgr->IsDomain() : false; - ASSERT_MSG(!_is_domain, - "Non-domain reply used on domain session\n" - "Service={} (TIPC={} CmdType={} Cmd=0x{:08X}\n" - "HasDomainHeader={} DomainHandlers={}\nDesc={}", - t.GetServiceName(), ctx.IsTipc(), - static_cast(ctx.GetCommandType()), static_cast(ctx.GetCommand()), - ctx.HasDomainMessageHeader(), _mgr ? static_cast(_mgr->DomainHandlerCount()) : 0u, - ctx.Description()); + const bool is_domain = mgr ? mgr->IsDomain() : false; + ASSERT_MSG(!is_domain, + "Non-domain reply used on domain session\n" + "Service={} (TIPC={} CmdType={} Cmd=0x{:08X}\n" + "HasDomainHeader={} DomainHandlers={}\nDesc={}", + t.GetServiceName(), ctx.IsTipc(), + u32(ctx.GetCommandType()), u32(ctx.GetCommand()), + ctx.HasDomainMessageHeader(), mgr ? u32(mgr->DomainHandlerCount()) : 0u, + ctx.Description()); } - const bool is_domain = Domain ? ctx.GetManager()->IsDomain() : false; + const bool is_domain = Domain ? mgr->IsDomain() : false; static_assert(ConstIfReference(), "Arguments taken by reference must be const"); using MethodArguments = std::tuple...>; diff --git a/src/core/hle/service/ipc_helpers.h b/src/core/hle/service/ipc_helpers.h index 4b02872fba..8aee17db8d 100644 --- a/src/core/hle/service/ipc_helpers.h +++ b/src/core/hle/service/ipc_helpers.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -78,32 +81,29 @@ public: memset(cmdbuf, 0, sizeof(u32) * IPC::COMMAND_BUFFER_LENGTH); IPC::CommandHeader header{}; + auto const mgr = ctx.GetManager().get(); // The entire size of the raw data section in u32 units, including the 16 bytes of mandatory // padding. - u32 raw_data_size = ctx.write_size = - ctx.IsTipc() ? normal_params_size - 1 : normal_params_size; + u32 raw_data_size = ctx.write_size = ctx.IsTipc() ? normal_params_size - 1 : normal_params_size; u32 num_handles_to_move{}; u32 num_domain_objects{}; - const bool always_move_handles{ - (static_cast(flags) & static_cast(Flags::AlwaysMoveHandles)) != 0}; - if (!ctx.GetManager()->IsDomain() || always_move_handles) { + const bool always_move_handles = (u32(flags) & u32(Flags::AlwaysMoveHandles)) != 0; + if (!mgr->IsDomain() || always_move_handles) { num_handles_to_move = num_objects_to_move; } else { num_domain_objects = num_objects_to_move; } - if (ctx.GetManager()->IsDomain()) { - raw_data_size += - static_cast(sizeof(DomainMessageHeader) / sizeof(u32) + num_domain_objects); + if (mgr->IsDomain()) { + raw_data_size += u32(sizeof(DomainMessageHeader) / sizeof(u32) + num_domain_objects); ctx.write_size += num_domain_objects; } if (ctx.IsTipc()) { header.type.Assign(ctx.GetCommandType()); } else { - raw_data_size += static_cast(sizeof(IPC::DataPayloadHeader) / sizeof(u32) + 4 + - normal_params_size); + raw_data_size += u32(sizeof(IPC::DataPayloadHeader) / sizeof(u32) + 4 + normal_params_size); } header.data_size.Assign(raw_data_size); @@ -126,7 +126,7 @@ public: if (!ctx.IsTipc()) { AlignWithPadding(); - if (ctx.GetManager()->IsDomain() && ctx.HasDomainMessageHeader()) { + if (mgr->IsDomain() && ctx.HasDomainMessageHeader()) { IPC::DomainMessageHeader domain_header{}; domain_header.num_objects = num_domain_objects; PushRaw(domain_header); From c0fbb2526d1ea9a0a270e1c88b80c5a13c03f7d6 Mon Sep 17 00:00:00 2001 From: crueter Date: Tue, 31 Mar 2026 21:13:50 +0200 Subject: [PATCH 2/8] [ci] Fix transifex workflow (#3805) Now should auto update Signed-off-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3805 --- .github/workflows/translations.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 92bb1fdf5d..16ce4f1808 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -3,8 +3,7 @@ name: tx-pull on: # monday, wednesday, saturday at 2pm schedule: - cron: - - '0 14 * * 1,3,6' + cron: '0 14 * * 1,3,6' workflow_dispatch: jobs: @@ -59,4 +58,3 @@ jobs: -H 'Authorization: Bearer ${{ secrets.CI_FJ_TOKEN }}' \ -H 'Content-Type: application/json' \ -d "@data.json" --fail - From 81a344f3dbd3cb549535c6486fd5269c5eacc528 Mon Sep 17 00:00:00 2001 From: xbzk Date: Tue, 31 Mar 2026 21:32:24 +0200 Subject: [PATCH 3/8] [android,addons] after a crash, launch button will wait for reloadGames to complete, and system will initialize after global config for proper firmware mounting (#3803) This fixes two problems: 1. After a crash, it was possible to launch a game before external content gets mounted. Now the button will wait for it to complete. 2. Directory initialization was init system before init globalconfig, so after a crash firmware was not being remounted (have you ever noticed fw version = N/A in device overlay, after fiddling with applets?) (this had been fixed in 3755, which was not thoroughly tested by cocoon dev) Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3803 Reviewed-by: Lizzie Reviewed-by: CamilleLaVey Co-authored-by: xbzk Co-committed-by: xbzk --- .../fragments/GamePropertiesFragment.kt | 17 ++++ .../org/yuzu/yuzu_emu/model/GamesViewModel.kt | 63 ++++++------ .../yuzu_emu/utils/DirectoryInitialization.kt | 2 +- .../org/yuzu/yuzu_emu/utils/GameHelper.kt | 99 ++++++++++++++----- 4 files changed, 128 insertions(+), 53 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt index faa35bc3eb..c3dea79bae 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -36,6 +36,7 @@ import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.features.settings.ui.SettingsSubscreen +import org.yuzu.yuzu_emu.model.AddonViewModel import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.GameProperty import org.yuzu.yuzu_emu.model.GamesViewModel @@ -46,6 +47,7 @@ import org.yuzu.yuzu_emu.model.SubmenuProperty import org.yuzu.yuzu_emu.model.TaskState import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GameHelper import org.yuzu.yuzu_emu.utils.GameIconUtils import org.yuzu.yuzu_emu.utils.GpuDriverHelper import org.yuzu.yuzu_emu.utils.MemoryUtil @@ -61,6 +63,7 @@ class GamePropertiesFragment : Fragment() { private val homeViewModel: HomeViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels() + private val addonViewModel: AddonViewModel by activityViewModels() private val driverViewModel: DriverViewModel by activityViewModels() private val args by navArgs() @@ -118,6 +121,20 @@ class GamePropertiesFragment : Fragment() { .show(childFragmentManager, LaunchGameDialogFragment.TAG) } + if (GameHelper.cachedGameList.isEmpty()) { + binding.buttonStart.isEnabled = false + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + GameHelper.restoreContentForGame(args.game) + } + if (_binding == null) { + return@launch + } + addonViewModel.onAddonsViewStarted(args.game) + binding.buttonStart.isEnabled = true + } + } + reloadList() homeViewModel.openImportSaves.collect( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt index 39ff038034..1a63a3ad82 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -100,42 +100,45 @@ class GamesViewModel : ViewModel() { viewModelScope.launch { withContext(Dispatchers.IO) { - if (firstStartup) { - // Retrieve list of cached games - val storedGames = - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - .getStringSet(GameHelper.KEY_GAMES, emptySet()) - if (storedGames!!.isNotEmpty()) { - val deserializedGames = mutableSetOf() - storedGames.forEach { - val game: Game - try { - game = Json.decodeFromString(it) - } catch (e: Exception) { - // We don't care about any errors related to parsing the game cache - return@forEach - } + try { + if (firstStartup) { + // Retrieve list of cached games + val storedGames = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf() + storedGames.forEach { + val game: Game + try { + game = Json.decodeFromString(it) + } catch (e: Exception) { + // We don't care about any errors related to parsing the game cache + return@forEach + } - val gameExists = - DocumentFile.fromSingleUri( - YuzuApplication.appContext, - Uri.parse(game.path) - )?.exists() - if (gameExists == true) { - deserializedGames.add(game) + val gameExists = + DocumentFile.fromSingleUri( + YuzuApplication.appContext, + Uri.parse(game.path) + )?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } } + setGames(deserializedGames.toList()) } - setGames(deserializedGames.toList()) } - } - setGames(GameHelper.getGames()) - reloading.set(false) - _isReloading.value = false - _shouldScrollAfterReload.value = true + setGames(GameHelper.getGames()) + _shouldScrollAfterReload.value = true - if (directoriesChanged) { - setShouldSwapData(true) + if (directoriesChanged) { + setShouldSwapData(true) + } + } finally { + reloading.set(false) + _isReloading.value = false } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt index f47c60491b..f961c5e984 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt @@ -23,8 +23,8 @@ object DirectoryInitialization { fun start() { if (!areDirectoriesReady) { initializeInternalStorage() - NativeLibrary.initializeSystem(false) NativeConfig.initializeGlobalConfig() + NativeLibrary.initializeSystem(false) NativeLibrary.reloadProfiles() migrateSettings() areDirectoriesReady = true diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index 4a3cf61daa..64e035afbe 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -8,9 +8,11 @@ package org.yuzu.yuzu_emu.utils import android.content.SharedPreferences import android.net.Uri +import android.provider.DocumentsContract import androidx.preference.PreferenceManager import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.io.File import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.Game @@ -49,29 +51,8 @@ object GameHelper { // Remove previous filesystem provider information so we can get up to date version info NativeLibrary.clearFilesystemProvider() - // Scan External Content directories and register all NSP/XCI files - val externalContentDirs = NativeConfig.getExternalContentDirs() - val uniqueExternalContentDirs = linkedSetOf() - externalContentDirs.forEach { externalDir -> - if (externalDir.isNotEmpty()) { - uniqueExternalContentDirs.add(externalDir) - } - } - val mountedContainerUris = mutableSetOf() - for (externalDir in uniqueExternalContentDirs) { - if (externalDir.isNotEmpty()) { - val externalDirUri = externalDir.toUri() - if (FileUtil.isTreeUriValid(externalDirUri)) { - scanContentContainersRecursive(FileUtil.listFiles(externalDirUri), 3) { - val containerUri = it.uri.toString() - if (mountedContainerUris.add(containerUri)) { - NativeLibrary.addFileToFilesystemProvider(containerUri) - } - } - } - } - } + mountExternalContentDirectories(mountedContainerUris) val badDirs = mutableListOf() gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> @@ -115,6 +96,15 @@ object GameHelper { return games.toList() } + fun restoreContentForGame(game: Game) { + NativeLibrary.reloadKeys() + + val mountedContainerUris = mutableSetOf() + mountExternalContentDirectories(mountedContainerUris) + mountGameFolderContent(Uri.parse(game.path), mountedContainerUris) + NativeLibrary.addFileToFilesystemProvider(game.path) + } + // File extensions considered as external content, buuut should // be done better imo. private val externalContentExtensions = setOf("nsp", "xci") @@ -181,6 +171,71 @@ object GameHelper { } } + private fun mountExternalContentDirectories(mountedContainerUris: MutableSet) { + val uniqueExternalContentDirs = linkedSetOf() + NativeConfig.getExternalContentDirs().forEach { externalDir -> + if (externalDir.isNotEmpty()) { + uniqueExternalContentDirs.add(externalDir) + } + } + + for (externalDir in uniqueExternalContentDirs) { + val externalDirUri = externalDir.toUri() + if (FileUtil.isTreeUriValid(externalDirUri)) { + scanContentContainersRecursive(FileUtil.listFiles(externalDirUri), 3) { + val containerUri = it.uri.toString() + if (mountedContainerUris.add(containerUri)) { + NativeLibrary.addFileToFilesystemProvider(containerUri) + } + } + } + } + } + + private fun mountGameFolderContent(gameUri: Uri, mountedContainerUris: MutableSet) { + if (gameUri.scheme == "content") { + val parentUri = getParentDocumentUri(gameUri) ?: return + scanContentContainersRecursive(FileUtil.listFiles(parentUri), 1) { + val containerUri = it.uri.toString() + if (mountedContainerUris.add(containerUri)) { + NativeLibrary.addGameFolderFileToFilesystemProvider(containerUri) + } + } + return + } + + val gameFile = File(gameUri.path ?: gameUri.toString()) + val parentDir = gameFile.parentFile ?: return + parentDir.listFiles()?.forEach { sibling -> + if (!sibling.isFile) { + return@forEach + } + + val extension = sibling.extension.lowercase() + if (externalContentExtensions.contains(extension)) { + val containerUri = Uri.fromFile(sibling).toString() + if (mountedContainerUris.add(containerUri)) { + NativeLibrary.addGameFolderFileToFilesystemProvider(containerUri) + } + } + } + } + + private fun getParentDocumentUri(uri: Uri): Uri? { + return try { + val documentId = DocumentsContract.getDocumentId(uri) + val separatorIndex = documentId.lastIndexOf('/') + if (separatorIndex == -1) { + null + } else { + val parentDocumentId = documentId.substring(0, separatorIndex) + DocumentsContract.buildDocumentUriUsingTree(uri, parentDocumentId) + } + } catch (_: Exception) { + null + } + } + fun getGame( uri: Uri, addedToLibrary: Boolean, From b4a485e244a742ce7ae2960b25ae0ab67bb0f5f2 Mon Sep 17 00:00:00 2001 From: xbzk Date: Tue, 31 Mar 2026 21:59:57 +0200 Subject: [PATCH 4/8] [android, intent] Added proper ext content mount and game swap support for intent launch (#3755) Required so that frontends can launch a game while there is already one running (for CocoonFE usage) Fix for mounting external content was merged. This patch also fixes multiple reasons for infinite game "Shutting down..." issue (hope all, who knows...) Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3755 Reviewed-by: CamilleLaVey Co-authored-by: xbzk Co-committed-by: xbzk --- .../yuzu_emu/activities/EmulationActivity.kt | 206 +++++++++++++++++- .../yuzu_emu/fragments/EmulationFragment.kt | 129 +++++++++-- 2 files changed, 307 insertions(+), 28 deletions(-) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index 2764d7eac6..44290fd4b6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -25,6 +25,11 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.navigation.NavOptions +import org.yuzu.yuzu_emu.fragments.EmulationFragment +import org.yuzu.yuzu_emu.utils.CustomSettingsHandler import android.util.Rational import android.view.InputDevice import android.view.KeyEvent @@ -87,6 +92,28 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager private val emulationViewModel: EmulationViewModel by viewModels() private var foregroundService: Intent? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var pendingRomSwapIntent: Intent? = null + private var isWaitingForRomSwapStop = false + private var romSwapNativeStopped = false + private var romSwapThreadStopped = false + private var romSwapGeneration = 0 + private var hasEmulationSession = processHasEmulationSession + private val romSwapStopTimeoutRunnable = Runnable { onRomSwapStopTimeout() } + + private fun onRomSwapStopTimeout() { + if (!isWaitingForRomSwapStop) { + return + } + Log.warning("[EmulationActivity] ROM swap stop timed out; retrying native stop and continuing to wait") + NativeLibrary.stopEmulation() + scheduleRomSwapStopTimeout() + } + + private fun scheduleRomSwapStopTimeout() { + mainHandler.removeCallbacks(romSwapStopTimeoutRunnable) + mainHandler.postDelayed(romSwapStopTimeoutRunnable, ROM_SWAP_STOP_TIMEOUT_MS) + } override fun attachBaseContext(base: Context) { super.attachBaseContext(YuzuApplication.applyLanguage(base)) @@ -128,9 +155,29 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager binding = ActivityEmulationBinding.inflate(layoutInflater) setContentView(binding.root) + val launchIntent = Intent(intent) + val shouldDeferLaunchForSwap = hasEmulationSession && isSwapIntent(launchIntent) + if (shouldDeferLaunchForSwap) { + Log.info("[EmulationActivity] onCreate detected existing session; deferring new game setup for swap") + emulationViewModel.setIsEmulationStopping(true) + emulationViewModel.setEmulationStopped(false) + } + val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras) + val initialArgs = if (shouldDeferLaunchForSwap) { + Bundle(intent.extras ?: Bundle()).apply { + processSessionGame?.let { putParcelable("game", it) } + } + } else { + intent.extras + } + navHostFragment.navController.setGraph(R.navigation.emulation_navigation, initialArgs) + if (shouldDeferLaunchForSwap) { + mainHandler.post { + handleSwapIntent(launchIntent) + } + } isActivityRecreated = savedInstanceState != null @@ -210,6 +257,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager } override fun onDestroy() { + mainHandler.removeCallbacks(romSwapStopTimeoutRunnable) super.onDestroy() inputManager.unregisterInputDeviceListener(this) stopForegroundService(this) @@ -228,17 +276,123 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - setIntent(intent) - - // Reset navigation graph with new intent data to recreate EmulationFragment - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras) - + handleSwapIntent(intent) nfcReader.onNewIntent(intent) InputHandler.updateControllerData() } + private fun isSwapIntent(intent: Intent): Boolean { + return when { + intent.getBooleanExtra(EXTRA_OVERLAY_GAMELESS_EDIT_MODE, false) -> false + intent.action == CustomSettingsHandler.CUSTOM_CONFIG_ACTION -> true + intent.data != null -> true + else -> { + val extras = intent.extras + extras != null && + BundleCompat.getParcelable(extras, EXTRA_SELECTED_GAME, Game::class.java) != null + } + } + } + + private fun handleSwapIntent(intent: Intent) { + if (!isSwapIntent(intent)) { + return + } + + pendingRomSwapIntent = Intent(intent) + + if (!isWaitingForRomSwapStop) { + Log.info("[EmulationActivity] Begin ROM swap: data=${intent.data}") + isWaitingForRomSwapStop = true + romSwapNativeStopped = false + romSwapThreadStopped = false + romSwapGeneration += 1 + val thisSwapGeneration = romSwapGeneration + emulationViewModel.setIsEmulationStopping(true) + emulationViewModel.setEmulationStopped(false) + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as? NavHostFragment + val childFragmentManager = navHostFragment?.childFragmentManager + val stoppingFragmentForSwap = + (childFragmentManager?.primaryNavigationFragment as? EmulationFragment) ?: + childFragmentManager + ?.fragments + ?.asReversed() + ?.firstOrNull { + it is EmulationFragment && + it.isAdded && + it.view != null && + !it.isRemoving + } as? EmulationFragment + + val hasSessionForSwap = hasEmulationSession || stoppingFragmentForSwap != null + + if (!hasSessionForSwap) { + romSwapNativeStopped = true + romSwapThreadStopped = true + } else { + if (stoppingFragmentForSwap != null) { + stoppingFragmentForSwap.stopForRomSwap() + stoppingFragmentForSwap.notifyWhenEmulationThreadStops { + if (!isWaitingForRomSwapStop || romSwapGeneration != thisSwapGeneration) { + return@notifyWhenEmulationThreadStops + } + romSwapThreadStopped = true + Log.info("[EmulationActivity] ROM swap thread stop acknowledged") + launchPendingRomSwap(force = false) + } + } else { + Log.warning("[EmulationActivity] ROM swap stop target fragment not found; requesting native stop") + romSwapThreadStopped = true + NativeLibrary.stopEmulation() + } + + scheduleRomSwapStopTimeout() + } + } + + launchPendingRomSwap(force = false) + } + + private fun launchPendingRomSwap(force: Boolean) { + if (!isWaitingForRomSwapStop) { + return + } + if (!force && (!romSwapNativeStopped || !romSwapThreadStopped)) { + return + } + val swapIntent = pendingRomSwapIntent ?: return + Log.info("[EmulationActivity] Launching pending ROM swap: data=${swapIntent.data}") + pendingRomSwapIntent = null + isWaitingForRomSwapStop = false + romSwapNativeStopped = false + romSwapThreadStopped = false + mainHandler.removeCallbacks(romSwapStopTimeoutRunnable) + applyGameLaunchIntent(swapIntent) + } + + private fun applyGameLaunchIntent(intent: Intent) { + hasEmulationSession = true + processHasEmulationSession = true + emulationViewModel.setIsEmulationStopping(false) + emulationViewModel.setEmulationStopped(false) + setIntent(Intent(intent)) + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + val navController = navHostFragment.navController + val startArgs = intent.extras?.let { Bundle(it) } ?: Bundle() + val navOptions = NavOptions.Builder() + .setPopUpTo(R.id.emulationFragment, true) + .build() + + runCatching { + navController.navigate(R.id.emulationFragment, startArgs, navOptions) + }.onFailure { + Log.warning("[EmulationActivity] ROM swap navigate fallback to setGraph: ${it.message}") + navController.setGraph(R.navigation.emulation_navigation, startArgs) + } + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || @@ -608,19 +762,48 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager } fun onEmulationStarted() { + if (Looper.myLooper() != Looper.getMainLooper()) { + mainHandler.post { onEmulationStarted() } + return + } + hasEmulationSession = true + processHasEmulationSession = true emulationViewModel.setEmulationStarted(true) + emulationViewModel.setIsEmulationStopping(false) + emulationViewModel.setEmulationStopped(false) NativeLibrary.playTimeManagerStart() } fun onEmulationStopped(status: Int) { - if (status == 0 && emulationViewModel.programChanged.value == -1) { + if (Looper.myLooper() != Looper.getMainLooper()) { + mainHandler.post { onEmulationStopped(status) } + return + } + hasEmulationSession = false + processHasEmulationSession = false + if (isWaitingForRomSwapStop) { + romSwapNativeStopped = true + Log.info("[EmulationActivity] ROM swap native stop acknowledged") + launchPendingRomSwap(force = false) + } else if (status == 0 && emulationViewModel.programChanged.value == -1) { + processSessionGame = null finish() + } else if (!isWaitingForRomSwapStop) { + processSessionGame = null } emulationViewModel.setEmulationStopped(true) } + fun updateSessionGame(game: Game?) { + processSessionGame = game + } + fun onProgramChanged(programIndex: Int) { + if (Looper.myLooper() != Looper.getMainLooper()) { + mainHandler.post { onProgramChanged(programIndex) } + return + } emulationViewModel.setProgramChanged(programIndex) } @@ -644,6 +827,11 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager companion object { const val EXTRA_SELECTED_GAME = "SelectedGame" const val EXTRA_OVERLAY_GAMELESS_EDIT_MODE = "overlayGamelessEditMode" + private const val ROM_SWAP_STOP_TIMEOUT_MS = 5000L + @Volatile + private var processHasEmulationSession = false + @Volatile + private var processSessionGame: Game? = null fun stopForegroundService(activity: Activity) { val startIntent = Intent(activity, ForegroundService::class.java) 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 435fe5fe2c..b67bc6a9cc 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 @@ -50,6 +50,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.findNavController +import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.navArgs import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowInfoTracker @@ -135,6 +136,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var intentGame: Game? = null private var isCustomSettingsIntent = false + private var isStoppingForRomSwap = false + private var deferGameSetupUntilStopCompletes = false private var perfStatsRunnable: Runnable? = null private var socRunnable: Runnable? = null @@ -238,6 +241,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + if (emulationViewModel.isEmulationStopping.value) { + deferGameSetupUntilStopCompletes = true + if (game == null) { + game = args.game ?: intentGame + } + return + } + finishGameSetup() } @@ -260,6 +271,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } game = gameToUse + emulationActivity?.updateSessionGame(gameToUse) } catch (e: Exception) { Log.error("[EmulationFragment] Error during game setup: ${e.message}") Toast.makeText( @@ -334,7 +346,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } emulationState = EmulationState(game!!.path) { - return@EmulationState driverViewModel.isInteractionAllowed.value + return@EmulationState driverViewModel.isInteractionAllowed.value && + !isStoppingForRomSwap } } @@ -890,8 +903,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } ) - GameIconUtils.loadGameIcon(game!!, binding.loadingImage) - binding.loadingTitle.text = game!!.title + game?.let { + GameIconUtils.loadGameIcon(it, binding.loadingImage) + binding.loadingTitle.text = it.title + } ?: run { + binding.loadingTitle.text = "" + } binding.loadingTitle.isSelected = true binding.loadingText.isSelected = true @@ -959,6 +976,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { ViewUtils.showView(binding.loadingIndicator) ViewUtils.hideView(binding.inputContainer) ViewUtils.hideView(binding.showStatsOverlayText) + } else if (deferGameSetupUntilStopCompletes) { + if (!isAdded) { + return@collect + } + deferGameSetupUntilStopCompletes = false + finishGameSetup() } } emulationViewModel.drawerOpen.collect(viewLifecycleOwner) { @@ -995,26 +1018,24 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { - if (it && !NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { - startEmulation() + if (it && + !isStoppingForRomSwap && + !NativeLibrary.isRunning() && + !NativeLibrary.isPaused() + ) { + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + + updateScreenLayout() + + emulationState.run(emulationActivity!!.isActivityRecreated) } } driverViewModel.onLaunchGame() } - private fun startEmulation(programIndex: Int = 0) { - if (!NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { - if (!DirectoryInitialization.areDirectoriesReady) { - DirectoryInitialization.start() - } - - updateScreenLayout() - - emulationState.run(emulationActivity!!.isActivityRecreated, programIndex) - } - } - override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val b = _binding ?: return @@ -1375,6 +1396,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { super.onDestroyView() amiiboLoadJob?.cancel() amiiboLoadJob = null + perfStatsRunnable?.let { perfStatsUpdateHandler.removeCallbacks(it) } + socRunnable?.let { socUpdateHandler.removeCallbacks(it) } + handler.removeCallbacksAndMessages(null) clearPausedFrame() _binding?.surfaceInputOverlay?.touchEventListener = null _binding = null @@ -1382,7 +1406,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } override fun onDetach() { - NativeLibrary.clearEmulationActivity() + if (!hasNewerEmulationFragment()) { + NativeLibrary.clearEmulationActivity() + } super.onDetach() } @@ -1840,10 +1866,74 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } override fun surfaceDestroyed(holder: SurfaceHolder) { - emulationState.clearSurface() + if (this::emulationState.isInitialized && !hasNewerEmulationFragment()) { + emulationState.clearSurface() + } emulationStarted = false } + private fun hasNewerEmulationFragment(): Boolean { + val activity = emulationActivity ?: return false + return try { + val navHostFragment = + activity.supportFragmentManager.findFragmentById(R.id.fragment_container) as? NavHostFragment + ?: return false + val currentFragment = navHostFragment.childFragmentManager.fragments + .filterIsInstance() + .firstOrNull() + currentFragment != null && currentFragment !== this + } catch (_: Exception) { + false + } + } + + // xbzk: called from EmulationActivity when a new game is loaded while this fragment is still active, + // to wait for the emulation thread to stop before allowing the ROM swap to proceed + fun notifyWhenEmulationThreadStops(onStopped: () -> Unit) { + if (!this::emulationState.isInitialized) { + onStopped() + return + } + val emuThread = runCatching { emulationState.emulationThread }.getOrNull() + if (emuThread == null || !emuThread.isAlive) { + onStopped() + return + } + Thread({ + runCatching { emuThread.join() } + Handler(Looper.getMainLooper()).post { + onStopped() + } + }, "RomSwapWait").start() + } + + // xbzk: called from EmulationActivity when a new game is loaded while this + // fragment is still active, to stop the current emulation before swapping the ROM + fun stopForRomSwap() { + if (isStoppingForRomSwap) { + return + } + isStoppingForRomSwap = true + clearPausedFrame() + emulationViewModel.setIsEmulationStopping(true) + _binding?.let { + binding.loadingText.setText(R.string.shutting_down) + ViewUtils.showView(binding.loadingIndicator) + ViewUtils.hideView(binding.inputContainer) + ViewUtils.hideView(binding.showStatsOverlayText) + } + if (this::emulationState.isInitialized) { + emulationState.stop() + if (NativeLibrary.isRunning() || NativeLibrary.isPaused()) { + Log.warning("[EmulationFragment] ROM swap stop fallback: forcing native stop request.") + NativeLibrary.stopEmulation() + } + } else { + NativeLibrary.stopEmulation() + } + NativeConfig.reloadGlobalConfig() + } + private fun showOverlayOptions() { val anchor = binding.inGameMenu.findViewById(R.id.menu_overlay_controls) val popup = PopupMenu(requireContext(), anchor) @@ -2134,6 +2224,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { state = State.STOPPED } else { Log.warning("[EmulationFragment] Stop called while already stopped.") + NativeLibrary.stopEmulation() } } From 21f9db1c272c5fd55aea2c9390535139a0782ba5 Mon Sep 17 00:00:00 2001 From: lizzie Date: Tue, 31 Mar 2026 23:45:06 +0200 Subject: [PATCH 5/8] [android] fix crash due to ctor/dtor ordering for std::random_device being uninitialized when used in other static ctor/dtors (#3806) wow that's fucking horrible Signed-off-by: lizzie Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3806 Reviewed-by: MaranBr Reviewed-by: CamilleLaVey Co-authored-by: lizzie Co-committed-by: lizzie --- src/common/random.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/common/random.cpp b/src/common/random.cpp index d6865ee807..d951881cd2 100644 --- a/src/common/random.cpp +++ b/src/common/random.cpp @@ -1,19 +1,22 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later +#include #include #include "common/random.h" -static std::random_device g_random_device; - namespace Common::Random { + [[nodiscard]] static std::random_device& GetGlobalRandomDevice() noexcept { + static std::random_device g_random_device{}; + return g_random_device; + } [[nodiscard]] u32 Random32(u32 seed) noexcept { - return g_random_device(); + return GetGlobalRandomDevice()(); } [[nodiscard]] u64 Random64(u64 seed) noexcept { - return g_random_device(); + return GetGlobalRandomDevice()(); } [[nodiscard]] std::mt19937 GetMT19937() noexcept { - return std::mt19937(g_random_device()); + return std::mt19937(GetGlobalRandomDevice()()); } } From bb71ace365dbec17736ec45d3a80affd1e16e498 Mon Sep 17 00:00:00 2001 From: crueter Date: Wed, 1 Apr 2026 02:08:46 +0200 Subject: [PATCH 6/8] [cmake] switch nightly auto updater to forgejo (#3807) Signed-off-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3807 --- CMakeModules/GenerateSCMRev.cmake | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeModules/GenerateSCMRev.cmake b/CMakeModules/GenerateSCMRev.cmake index 5b0adad8dd..947a4963ee 100644 --- a/CMakeModules/GenerateSCMRev.cmake +++ b/CMakeModules/GenerateSCMRev.cmake @@ -37,10 +37,10 @@ set(GIT_DESC ${BUILD_VERSION}) # Auto-updater metadata! Must somewhat mirror GitHub API endpoint if (NIGHTLY_BUILD) - set(BUILD_AUTO_UPDATE_WEBSITE "https://github.com") - set(BUILD_AUTO_UPDATE_API "api.github.com") - set(BUILD_AUTO_UPDATE_API_PATH "/repos/") - set(BUILD_AUTO_UPDATE_REPO "Eden-CI/Nightly") + set(BUILD_AUTO_UPDATE_WEBSITE "https://git.eden-emu.dev") + set(BUILD_AUTO_UPDATE_API "git.eden-emu.dev") + set(BUILD_AUTO_UPDATE_API_PATH "/api/v1/repos/") + set(BUILD_AUTO_UPDATE_REPO "eden-ci/nightly") set(REPO_NAME "Eden Nightly") else() set(BUILD_AUTO_UPDATE_WEBSITE "https://git.eden-emu.dev") From 6e760148248801e986ec6d640621d1a3af97763a Mon Sep 17 00:00:00 2001 From: crueter Date: Wed, 1 Apr 2026 02:09:56 +0200 Subject: [PATCH 7/8] [ci] Move workflows to `.forgejo` (#3808) Primarily to prevent mirrors from trying to run bogus actions. Signed-off-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3808 --- {.github => .forgejo}/ISSUE_TEMPLATE/blank_issue_template.yml | 0 {.github => .forgejo}/ISSUE_TEMPLATE/bug_report.yml | 0 {.github => .forgejo}/ISSUE_TEMPLATE/config.yml | 0 {.github => .forgejo}/ISSUE_TEMPLATE/feature_request.yml | 0 {.github => .forgejo}/workflows/license-header.yml | 0 {.github => .forgejo}/workflows/sources.yml | 0 {.github => .forgejo}/workflows/strings.yml | 0 {.github => .forgejo}/workflows/translations.yml | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename {.github => .forgejo}/ISSUE_TEMPLATE/blank_issue_template.yml (100%) rename {.github => .forgejo}/ISSUE_TEMPLATE/bug_report.yml (100%) rename {.github => .forgejo}/ISSUE_TEMPLATE/config.yml (100%) rename {.github => .forgejo}/ISSUE_TEMPLATE/feature_request.yml (100%) rename {.github => .forgejo}/workflows/license-header.yml (100%) rename {.github => .forgejo}/workflows/sources.yml (100%) rename {.github => .forgejo}/workflows/strings.yml (100%) rename {.github => .forgejo}/workflows/translations.yml (100%) diff --git a/.github/ISSUE_TEMPLATE/blank_issue_template.yml b/.forgejo/ISSUE_TEMPLATE/blank_issue_template.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/blank_issue_template.yml rename to .forgejo/ISSUE_TEMPLATE/blank_issue_template.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.forgejo/ISSUE_TEMPLATE/bug_report.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .forgejo/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/config.yml rename to .forgejo/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.forgejo/ISSUE_TEMPLATE/feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .forgejo/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/workflows/license-header.yml b/.forgejo/workflows/license-header.yml similarity index 100% rename from .github/workflows/license-header.yml rename to .forgejo/workflows/license-header.yml diff --git a/.github/workflows/sources.yml b/.forgejo/workflows/sources.yml similarity index 100% rename from .github/workflows/sources.yml rename to .forgejo/workflows/sources.yml diff --git a/.github/workflows/strings.yml b/.forgejo/workflows/strings.yml similarity index 100% rename from .github/workflows/strings.yml rename to .forgejo/workflows/strings.yml diff --git a/.github/workflows/translations.yml b/.forgejo/workflows/translations.yml similarity index 100% rename from .github/workflows/translations.yml rename to .forgejo/workflows/translations.yml From 04d9b0812aba9a21aea876f2b49b3859293d4bc8 Mon Sep 17 00:00:00 2001 From: MaranBr Date: Fri, 27 Mar 2026 16:06:30 -0400 Subject: [PATCH 8/8] Fix IsScaled dynamic indexing reading wrong bit source --- src/shader_recompiler/backend/spirv/emit_spirv_image.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shader_recompiler/backend/spirv/emit_spirv_image.cpp b/src/shader_recompiler/backend/spirv/emit_spirv_image.cpp index c4c898bec9..710efaf23f 100644 --- a/src/shader_recompiler/backend/spirv/emit_spirv_image.cpp +++ b/src/shader_recompiler/backend/spirv/emit_spirv_image.cpp @@ -285,8 +285,11 @@ Id IsScaled(EmitContext& ctx, const IR::Value& index, Id member_index, u32 base_ if (base_index != 0) { index_value = ctx.OpIAdd(ctx.U32[1], index_value, ctx.Const(base_index)); } + const Id word_index{ctx.OpUDiv(ctx.U32[1], index_value, ctx.Const(32u))}; + const Id pointer{ctx.OpAccessChain(push_constant_u32, ctx.rescaling_push_constants, member_index, word_index)}; + const Id word{ctx.OpLoad(ctx.U32[1], pointer)}; const Id bit_index{ctx.OpBitwiseAnd(ctx.U32[1], index_value, ctx.Const(31u))}; - bit = ctx.OpBitFieldUExtract(ctx.U32[1], index_value, bit_index, ctx.Const(1u)); + bit = ctx.OpBitFieldUExtract(ctx.U32[1], word, bit_index, ctx.Const(1u)); } return ctx.OpINotEqual(ctx.U1, bit, ctx.u32_zero_value); }