diff --git a/android/README.md b/android/README.md index cd4e5c7..d78a84a 100644 --- a/android/README.md +++ b/android/README.md @@ -46,6 +46,8 @@ The app opens on a 2D launcher panel modelled on the PC launcher (WheelWizard VR still missing (the disc files, the game itself, or Retro Rewind's pack), and **Settings** edits `Config.toml` (VR camera, render scale, virtual screen, resolution, controllers, audio), with an **Other** tab for the files and the -games built from them and an **About** tab carrying the credits. The game itself is `QuestActivity`, in its own +games built from them, with a **Reset installation** action that removes the game files (and +on request the built games and the Retro Rewind pack) so they can be set up again, and an +**About** tab carrying the credits. The game itself is `QuestActivity`, in its own `:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity` still starts it directly. diff --git a/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt index 0a5ead5..31ae053 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt @@ -53,6 +53,7 @@ class QuestActivity : SDLActivity() { override fun loadLibraries() { super.loadLibraries() // SDLActivity reports a failed load in its own error dialog and never starts the game. + storageFailure?.let { throw UnsatisfiedLinkError(getString(R.string.game_storage_failed, it)) } if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) { throw UnsatisfiedLinkError(getString(R.string.game_not_installed)) } @@ -63,8 +64,18 @@ class QuestActivity : SDLActivity() { override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context) + /** Why the game directory could not be prepared, reported through SDL's own error dialog. */ + private var storageFailure: String? = null + override fun onCreate(savedInstanceState: Bundle?) { - GameStorage.prepare(this) + try { + GameStorage.prepare(this) + } catch (e: Exception) { + // Storage the app cannot write (a directory adb created first, a full disk) would + // otherwise end the game with nothing on screen. + Log.e(TAG, "Cannot prepare the game directory", e) + storageFailure = e.message ?: e.toString() + } val resources = unpackRuntimeResources() Os.setenv("MKW_ANDROID_DATA_DIR", GameStorage.dataRoot(this).absolutePath, true) diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt index 30ae7c4..2ee6003 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt @@ -18,13 +18,14 @@ import org.wiicompiled.quest.GameProfile * the package carries it ([GamePackageImport]). * - [Task.BuildGame]: the game library is built on the headset from DATA ([GameBuild]). * - [Task.DownloadModPack]: Retro Rewind's own server provides its pack ([RetroRewindPack]). + * - [Task.Reset]: what the app put on the headset is removed, to be set up again ([InstallReset]). * * Every task stages its output next to the destination and swaps it in only after it has been * checked, so a failed, cancelled or killed run never costs working files. */ object GameSetup { - enum class Task { ExtractDisc, ImportPackage, BuildGame, DownloadModPack } + enum class Task { ExtractDisc, ImportPackage, BuildGame, DownloadModPack, Reset } sealed interface State { data object Idle : State @@ -88,8 +89,18 @@ object GameSetup { } } - /** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. [uri] is null only for a build. */ - fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean, profile: GameProfile = GameProfile.selected(context)) { + /** + * The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. [uri] is null + * for a build, a pack download and a reset; [reset] says what a reset removes. + */ + fun run( + context: Context, + task: Task, + uri: Uri?, + deleteSource: Boolean, + profile: GameProfile = GameProfile.selected(context), + reset: InstallReset.Options = InstallReset.Options(gameFiles = true, games = false, modPack = false), + ) { val progress = Progress { done, total -> publish(State.Working(task, done, total)) !cancelRequested @@ -104,6 +115,8 @@ object GameSetup { GameBuild.run(context, profile, reporter, cancelled = { cancelRequested }, finishing) } else if (task == Task.DownloadModPack) { RetroRewindPack.run(context, progress, cancelled = { cancelRequested }, finishing) + } else if (task == Task.Reset) { + InstallReset.run(context, reset, progress, finishing) } else { uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor -> when (task) { diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt index b8e1951..a640247 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt @@ -40,9 +40,11 @@ class GameSetupService : Service() { createChannel() val task = intent?.getStringExtra(EXTRA_TASK)?.let { name -> GameSetup.Task.entries.firstOrNull { it.name == name } } ?: GameSetup.Task.ExtractDisc - startForeground(NOTIFICATION_ID, notification(GameSetup.State.Checking(task)), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + // A task already going keeps its own notification rather than the refused one's. + val shown = if (GameSetup.isRunning) GameSetup.state else GameSetup.State.Checking(task) + startForeground(NOTIFICATION_ID, notification(shown), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) val uri = intent?.data - val needsUri = task != GameSetup.Task.BuildGame && task != GameSetup.Task.DownloadModPack + val needsUri = task != GameSetup.Task.BuildGame && task != GameSetup.Task.DownloadModPack && task != GameSetup.Task.Reset if ((uri == null && needsUri) || !GameSetup.begin(task)) { // A task already going keeps the service, and ends it itself. if (!GameSetup.isRunning) { @@ -54,13 +56,18 @@ class GameSetupService : Service() { GameSetup.addListener(listener) val deleteSource = intent?.getBooleanExtra(EXTRA_DELETE_SOURCE, false) ?: false val profile = GameProfile.of(intent?.getStringExtra(EXTRA_PROFILE)) ?: GameProfile.selected(this) + val reset = InstallReset.Options( + gameFiles = intent?.getBooleanExtra(EXTRA_RESET_GAME_FILES, true) ?: true, + games = intent?.getBooleanExtra(EXTRA_RESET_GAMES, false) ?: false, + modPack = intent?.getBooleanExtra(EXTRA_RESET_MOD_PACK, false) ?: false, + ) val wakeLock = getSystemService(PowerManager::class.java) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup") wakeLock.acquire(if (task == GameSetup.Task.BuildGame) BUILD_WAKE_LOCK_TIMEOUT_MS else WAKE_LOCK_TIMEOUT_MS) thread(name = "GameSetup") { try { - GameSetup.run(applicationContext, task, uri, deleteSource, profile) + GameSetup.run(applicationContext, task, uri, deleteSource, profile, reset) } finally { uri?.let(::releaseReadPermission) if (wakeLock.isHeld) wakeLock.release() @@ -105,6 +112,7 @@ class GameSetupService : Service() { GameSetup.Task.BuildGame -> R.string.disc_setup_building GameSetup.Task.ExtractDisc -> R.string.disc_setup_extracting GameSetup.Task.DownloadModPack -> R.string.mod_pack_downloading + GameSetup.Task.Reset -> R.string.disc_setup_resetting } builder.setContentText(getString(text, percent)).setProgress(100, percent, false) } else { @@ -121,6 +129,9 @@ class GameSetupService : Service() { private const val EXTRA_TASK = "task" private const val EXTRA_PROFILE = "profile" private const val EXTRA_DELETE_SOURCE = "deleteSource" + private const val EXTRA_RESET_GAME_FILES = "resetGameFiles" + private const val EXTRA_RESET_GAMES = "resetGames" + private const val EXTRA_RESET_MOD_PACK = "resetModPack" // Longer than any real task; only a hung run would reach it. private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000 private const val BUILD_WAKE_LOCK_TIMEOUT_MS = 3L * 60 * 60 * 1000 @@ -132,6 +143,16 @@ class GameSetupService : Service() { context.startForegroundService(intent) } + /** Removes what [options] select, so the game can be set up again ([InstallReset]). */ + fun startReset(context: Context, options: InstallReset.Options) { + val intent = Intent(context, GameSetupService::class.java) + .putExtra(EXTRA_TASK, GameSetup.Task.Reset.name) + .putExtra(EXTRA_RESET_GAME_FILES, options.gameFiles) + .putExtra(EXTRA_RESET_GAMES, options.games) + .putExtra(EXTRA_RESET_MOD_PACK, options.modPack) + context.startForegroundService(intent) + } + /** Builds [profile]'s game from DATA on this headset ([GameBuild]). */ fun startBuild(context: Context, profile: GameProfile) { val intent = Intent(context, GameSetupService::class.java) diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/InstallReset.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/InstallReset.kt new file mode 100644 index 0000000..6d1695d --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/InstallReset.kt @@ -0,0 +1,80 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.InterruptedIOException +import org.wiicompiled.quest.GameStorage + +/** + * Removes what the app put on the headset so the game can be set up again: the game files (DATA) + * with the leftovers of an interrupted extraction or import, the built games with the on-device + * build workspace, and the Retro Rewind pack with its own leftovers. Config.toml, the saves (NAND) + * and the logs are never touched, so a reset costs no progress. + */ +object InstallReset { + + /** What to remove; at least one must be set for a reset to do anything. */ + data class Options(val gameFiles: Boolean, val games: Boolean, val modPack: Boolean) { + val anything: Boolean get() = gameFiles || games || modPack + } + + private const val TAG = "WiiCompiledLauncher" + private const val REPORT_EVERY = 64L + + /** Null on success, otherwise the message to show. */ + fun run(context: Context, options: Options, progress: GameSetup.Progress, finishing: () -> Unit): String? { + val roots = targets( + GameStorage.gameRoot(context), + File(context.filesDir, "game"), + File(context.filesDir, "build"), + options, + ) + val total = roots.sumOf { countEntries(it) }.toLong() + if (!progress.update(0, total)) throw InterruptedIOException("Reset cancelled") + var done = 0L + val kept = mutableListOf() + for (root in roots) { + Log.i(TAG, "Removing ${root.absolutePath}") + deleteTree(root, kept) { + done++ + if (done % REPORT_EVERY == 0L && !progress.update(done, total)) { + throw InterruptedIOException("Reset cancelled") + } + } + } + finishing() + if (kept.isNotEmpty()) { + Log.w(TAG, "${kept.size} entries could not be removed, first ${kept.first().absolutePath}") + return "${kept.size} files could not be removed, for example ${kept.first().absolutePath}. " + + "Remove them over adb (adb shell rm -r), then try again." + } + Log.i(TAG, "Reset removed ${roots.size} trees, $done entries") + return null + } + + /** + * The trees [options] select, as they lie on disk: DATA and any `DATA.*` staging leftover + * beside it, every built game plus the build workspace, the pack and its own leftovers. + */ + fun targets(gameRoot: File, gamesDir: File, buildDir: File, options: Options): List { + val roots = mutableListOf() + if (options.gameFiles) roots += siblings(gameRoot, GameStorage.DISC_DIRECTORY) + if (options.games) roots += listOf(gamesDir, buildDir) + if (options.modPack) roots += siblings(gameRoot, GameStorage.MOD_DIRECTORY) + return roots.filter { it.exists() } + } + + private fun siblings(parent: File, name: String): List = + parent.listFiles { file -> file.name == name || file.name.startsWith("$name.") }?.sortedBy { it.name } ?: emptyList() + + fun countEntries(root: File): Int = root.walkBottomUp().count() + + /** Deletes [root] bottom-up, calling [onEntry] per entry; whatever refuses to go joins [kept]. */ + fun deleteTree(root: File, kept: MutableList, onEntry: () -> Unit) { + for (entry in root.walkBottomUp()) { + onEntry() + if (!entry.delete() && entry.exists()) kept += entry + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index 45c6633..b12b2a5 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -13,6 +13,7 @@ import android.text.method.ScrollingMovementMethod import android.util.Log import android.view.View import android.view.ViewGroup +import android.widget.CheckBox import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar @@ -43,7 +44,7 @@ class LauncherActivity : Activity() { private enum class Page { Home, Settings } /** What Home's main and secondary buttons do. */ - private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack } + private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } private lateinit var navHome: View private lateinit var navSettings: View @@ -126,6 +127,7 @@ class LauncherActivity : Activity() { importGame = ::importGame, buildGame = ::buildGame, downloadModPack = ::downloadModPack, + resetInstallation = ::resetInstallation, ) savedInstanceState?.getString(KEY_TAB)?.let { name -> SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) @@ -226,12 +228,19 @@ class LauncherActivity : Activity() { val importing = task == GameSetup.Task.ImportPackage val building = task == GameSetup.Task.BuildGame val downloadingPack = task == GameSetup.Task.DownloadModPack + val resetting = task == GameSetup.Task.Reset // Without anything yet, a player with only a headset starts from their disc image, and // builds the game once its files are there; a PC-built game can always be imported instead. // Retro Rewind also needs its own pack, which neither building nor playing can do without. + // Game files that are there but unusable, or an attempt to set them up that failed, lead + // with a reset: what the app put on the headset is removed and set up again. + val gameFilesTask = task == GameSetup.Task.ExtractDisc || task == GameSetup.Task.ImportPackage || resetting + val filesBroken = discStatus == GameStorage.DiscStatus.Incomplete || + (setup is GameSetup.State.Failed && gameFilesTask && discStatus != GameStorage.DiscStatus.Ready) mainAction = when { running -> Action.Resume + filesBroken -> Action.Reset discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc !GameStorage.modContentReady(this, profile) -> Action.DownloadModPack gameStatus != GameLibrary.Status.Ready -> Action.BuildGame @@ -239,6 +248,8 @@ class LauncherActivity : Activity() { } secondaryAction = when { settingUp || running -> null + mainAction == Action.Reset -> Action.SelectDisc + setup is GameSetup.State.Failed && gameFilesTask -> Action.Reset mainAction == Action.ImportGame -> null gameStatus != GameLibrary.Status.Ready || mainAction == Action.SelectDisc -> Action.ImportGame else -> null @@ -249,7 +260,7 @@ class LauncherActivity : Activity() { playText.text = when { setup is GameSetup.State.Checking -> getString( when { - building -> R.string.home_build_preparing + building || resetting -> R.string.home_build_preparing importing -> R.string.home_checking_package else -> R.string.home_checking }, @@ -259,6 +270,7 @@ class LauncherActivity : Activity() { building -> R.string.home_building importing -> R.string.home_importing downloadingPack -> R.string.home_mod_pack_downloading + resetting -> R.string.home_resetting else -> R.string.home_extracting }, percent(setup), @@ -278,6 +290,9 @@ class LauncherActivity : Activity() { homeStatus.text = when { setup is GameSetup.State.Working && building -> buildStatus(setup) + setup is GameSetup.State.Working && resetting -> getString(R.string.home_reset_progress, setup.done, setup.total) + setup is GameSetup.State.Checking && resetting -> getString(R.string.home_reset_status) + setup is GameSetup.State.Done && resetting -> getString(R.string.home_reset_done) setup is GameSetup.State.Working -> getString( R.string.home_extract_progress, Formatter.formatShortFileSize(this, setup.done), @@ -291,7 +306,13 @@ class LauncherActivity : Activity() { }, ) settingUp || setup is GameSetup.State.Failed -> "" - setup is GameSetup.State.Cancelled -> getString(if (building) R.string.home_build_cancelled else R.string.home_setup_cancelled) + setup is GameSetup.State.Cancelled -> getString( + when { + building -> R.string.home_build_cancelled + resetting -> R.string.home_reset_cancelled + else -> R.string.home_setup_cancelled + }, + ) running -> getString(R.string.home_running) mainAction == Action.Play && setup is GameSetup.State.Done -> getString(if (building) R.string.home_build_done else R.string.home_setup_done) mainAction == Action.Play -> getString(R.string.home_put_on_headset) @@ -301,9 +322,11 @@ class LauncherActivity : Activity() { when { settingUp && building -> showBanner(getString(R.string.home_build_running), warning = false) settingUp && downloadingPack -> showBanner(getString(R.string.home_mod_pack_running), warning = false) + settingUp && resetting -> showBanner(getString(R.string.home_reset_running), warning = false) settingUp -> showBanner(null) setup is GameSetup.State.Failed && building -> showBanner(getString(R.string.home_build_failed, setup.message), warning = true) setup is GameSetup.State.Failed && downloadingPack -> showBanner(getString(R.string.home_mod_pack_failed, setup.message), warning = true) + setup is GameSetup.State.Failed && resetting -> showBanner(getString(R.string.home_reset_failed, setup.message), warning = true) setup is GameSetup.State.Failed -> showBanner(getString(R.string.home_setup_failed, setup.message), warning = true) discStatus == GameStorage.DiscStatus.Incomplete -> showBanner(getString(R.string.home_data_incomplete, disc), warning = true) // Only once the disc files are there is the pack the next thing missing; before that a @@ -365,6 +388,7 @@ class LauncherActivity : Activity() { Action.ImportGame -> R.string.home_import Action.BuildGame -> R.string.home_build Action.DownloadModPack -> R.string.home_download_mod_pack + Action.Reset -> R.string.home_reset } private fun perform(action: Action) { @@ -375,15 +399,68 @@ class LauncherActivity : Activity() { Action.ImportGame -> importGame() Action.BuildGame -> buildGame() Action.DownloadModPack -> downloadModPack() + Action.Reset -> resetInstallation() } } + /** The game reads its files while it runs, so nothing that replaces them starts meanwhile. */ + private fun refuseWhileGameRuns(): Boolean { + if (!isGameRunning()) return false + Toast.makeText(this, R.string.home_close_game_first, Toast.LENGTH_LONG).show() + return true + } + + /** + * Removes what the app put on the headset so the game can be set up again from scratch: the + * game files, and on request the built games and the Retro Rewind pack. Saves and settings are + * never touched. The choice is a small form rather than a list dialog, which would hide the + * explanation. + */ + private fun resetInstallation() { + if (GameSetup.isRunning || refuseWhileGameRuns()) return + val form = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(20.dp(), 12.dp(), 20.dp(), 0) + } + form.addView( + TextView(this).apply { + setText(R.string.home_reset_message) + setTextColor(getColor(R.color.neutral_300)) + textSize = 14f + }, + ) + val choices = listOf( + R.string.home_reset_option_game_files to true, + R.string.home_reset_option_games to false, + R.string.home_reset_option_mod_pack to false, + ).map { (label, checked) -> + CheckBox(this).apply { + setText(label) + setTextColor(getColor(R.color.neutral_100)) + isChecked = checked + setPadding(8.dp(), 10.dp(), 0, 10.dp()) + form.addView(this) + } + } + AlertDialog.Builder(this) + .setTitle(R.string.home_reset_title) + .setView(form) + .setPositiveButton(R.string.home_reset) { _, _ -> + val options = InstallReset.Options(choices[0].isChecked, choices[1].isChecked, choices[2].isChecked) + if (!options.anything || GameSetup.isRunning || refuseWhileGameRuns()) return@setPositiveButton + GameSetupService.startReset(this, options) + showPage(Page.Home) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + /** * Fetches Retro Rewind's pack from Retro Rewind's own server, as the computer launcher does, * after saying where it comes from and how big it is. */ private fun downloadModPack() { - if (GameSetup.isRunning) return + if (GameSetup.isRunning || refuseWhileGameRuns()) return val installed = RetroRewindPack.installedVersion(this) val message = if (installed == null) { getString(R.string.home_mod_pack_message) @@ -407,7 +484,7 @@ class LauncherActivity : Activity() { /** Builds the selected game on this headset from DATA, after saying what that takes. */ private fun buildGame() { - if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return + if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready || refuseWhileGameRuns()) return if (!GameStorage.modContentReady(this, profile)) { confirm(R.string.home_mod_needed_title, R.string.home_mod_needed_message, R.string.home_download_mod_pack) { downloadModPack() } return @@ -438,7 +515,7 @@ class LauncherActivity : Activity() { /** Opens the document picker for a disc image, asking first when it would replace DATA. */ private fun selectDiscImage() { - if (GameSetup.isRunning) return + if (GameSetup.isRunning || refuseWhileGameRuns()) return if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing) { openPicker(REQUEST_DISC_IMAGE) return @@ -450,7 +527,7 @@ class LauncherActivity : Activity() { /** Opens the document picker for a .wcgame, asking first when it would replace the game. */ private fun importGame() { - if (GameSetup.isRunning) return + if (GameSetup.isRunning || refuseWhileGameRuns()) return if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) { openPicker(REQUEST_GAME_PACKAGE) return @@ -493,7 +570,8 @@ class LauncherActivity : Activity() { * adb pushed belongs to the shell user, so the app cannot always delete it afterwards. */ private fun importDroppedPackage() { - if (GameSetup.isRunning) return + // Not while the game reads its files; the package is still there at the next resume. + if (GameSetup.isRunning || isGameRunning()) return val dropped = GameStorage.importDirectory(this) .listFiles { file -> file.isFile && file.name.endsWith(".wcgame", ignoreCase = true) } ?.maxByOrNull { it.lastModified() } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroRewindPack.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroRewindPack.kt index 83e9cf8..e0287e5 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroRewindPack.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/RetroRewindPack.kt @@ -86,6 +86,20 @@ object RetroRewindPack { .sortedWith { a, b -> compare(a.version, b.version) } .mapNotNull { packRelative(it.path) } + /** + * The updates to apply over an installation holding [installed], each with the pack-relative + * paths its version deletes, in the order the PC launcher applies them: an update, then its + * deletions, then the next update. + */ + fun steps(installed: String?, updates: List, deletions: List): List>> { + var previous = installed + return updates.sortedWith { a, b -> compare(a.version, b.version) }.map { update -> + val dropped = deletionsBetween(previous, update.version, deletions) + previous = update.version + update to dropped + } + } + /** Dotted numeric comparison, which is all these versions ever are (6.12.7). */ fun compare(left: String, right: String): Int { val a = left.split('.') @@ -164,16 +178,15 @@ object RetroRewindPack { } // Every update is a partial tree that lands on top of the installed pack, so they are - // applied in order and the version is only written once all of them are in. + // applied in order, each followed by its own deletions before the next one can put a + // file back, and the version is only written once all of them are in. val remaining = updatesAfter(installedVersion(context), updates) if (remaining.isNotEmpty()) { val deletions = parseDeletions(fetchText(DELETE_URL, cancelled)) - for (update in remaining) { + for ((update, dropped) in steps(installedVersion(context), remaining, deletions)) { Log.i(TAG, "Applying Retro Rewind ${update.version}") download(update.url, pack, progress, cancelled) - } - for (relative in deletionsBetween(installedVersion(context), remaining.last().version, deletions)) { - File(pack, relative).deleteRecursively() + for (relative in dropped) File(pack, relative).deleteRecursively() } finishing() File(pack, VERSION_FILE).writeText(remaining.last().version) diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index 211bf60..5c1cc4a 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -41,6 +41,7 @@ class SettingsPage( private val importGame: () -> Unit, private val buildGame: () -> Unit, private val downloadModPack: () -> Unit, + private val resetInstallation: () -> Unit, ) { enum class Tab(val label: Int) { @@ -275,7 +276,9 @@ class SettingsPage( GameStorage.DiscStatus.Missing -> R.string.about_game_data_missing } info(R.string.about_game_data, activity.getString(status, disc), stacked = true) - action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) { + // Everything below replaces files the game reads, so nothing starts while it runs. + val idle = !GameSetup.isRunning && !gameRunning() + action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = idle) { selectDiscImage() } // One row per game this app carries a kit for, so both are visible at once. @@ -293,14 +296,17 @@ class SettingsPage( if (BuildConfig.ON_DEVICE_BUILD) { action( R.string.home_build, R.string.about_build_helper, R.string.home_build, - enabled = !GameSetup.isRunning && GameStorage.discStatus(activity) == GameStorage.DiscStatus.Ready, + enabled = idle && GameStorage.discStatus(activity) == GameStorage.DiscStatus.Ready, ) { buildGame() } } - action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) { + action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = idle) { importGame() } + action(R.string.about_reset, R.string.about_reset_helper, R.string.home_reset, enabled = idle) { + resetInstallation() + } // Retro Rewind's pack is its own download, and the row says which version is installed. if (GameProfile.RetroRewind in GameProfile.available(activity)) { val installed = RetroRewindPack.installedVersion(activity) @@ -314,7 +320,7 @@ class SettingsPage( R.string.about_mod_pack_action, R.string.about_mod_pack_helper, if (installed == null) R.string.home_download_mod_pack else R.string.about_mod_pack_update, - enabled = !GameSetup.isRunning, + enabled = idle, ) { downloadModPack() } @@ -437,6 +443,10 @@ class SettingsPage( ) { val initial = read(config) var committed = initial + // A value the runtime accepts but the slider does not reach (a hand edit) widens the + // slider to it, so the bar shows the real value and a nudge changes it by one step. + val low = minOf(min, initial) + val high = maxOf(max, initial) val valueText = TextView(activity).apply { text = format(initial) setTextColor(activity.getColor(R.color.neutral_100)) @@ -444,17 +454,17 @@ class SettingsPage( gravity = Gravity.END or Gravity.CENTER_VERTICAL layoutParams = LinearLayout.LayoutParams(dp(64), LinearLayout.LayoutParams.WRAP_CONTENT) } - val steps = ((max - min) / step).roundToInt() + val steps = ((high - low) / step).roundToInt() val seekBar = SeekBar(activity).apply { this.max = steps - progress = ((initial.coerceIn(min, max) - min) / step).roundToInt() + progress = ((initial.coerceIn(low, high) - low) / step).roundToInt() progressTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_400)) thumbTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_300)) progressBackgroundTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_600)) layoutParams = LinearLayout.LayoutParams(dp(210), LinearLayout.LayoutParams.WRAP_CONTENT) } // Rounded so 0.25 + 15 * 0.05 is 1.0 again and an untouched value is not rewritten. - fun valueAt(progress: Int) = (min + progress * step).let { Math.round(it * 1e6) / 1e6 } + fun valueAt(progress: Int) = (low + progress * step).let { Math.round(it * 1e6) / 1e6 } fun commitValue(value: Double) { if (value != committed) { committed = value diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0a9157a..bd0a746 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -77,6 +77,23 @@ Replace the game files? Your current DATA folder stays until the new disc image has been checked and completely extracted. The game could not be started. + Close the game first. + Reset installation + Reset the installation? + Removes what you tick below so it can be set up again from scratch. Your saves and settings stay. + Game files (DATA), with any unfinished extraction or import + Built games and the build workspace + Retro Rewind pack (RetroRewind6) + Resetting %1$d%% + Counting the files to remove. + %1$d of %2$d files removed + Removing the files you chose. The launcher stays usable meanwhile. + The installation was reset. Select your disc image to set the game up again. + Reset cancelled. What was already removed stays removed. + The installation could not be reset completely.\n%1$s + Reset installation + Removes the game files, and if you tick them the built games and the Retro Rewind pack, so they can be set up again. Saves and settings stay. + The game folder could not be prepared: %1$s VR @@ -230,6 +247,7 @@ Game file setup Setting up the game files Checking the disc image… + Resetting the installation: %1$d%% Extracting: %1$d%% Building: %1$d%% Building the game diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/InstallResetTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/InstallResetTest.kt new file mode 100644 index 0000000..10c9802 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/InstallResetTest.kt @@ -0,0 +1,69 @@ +package org.wiicompiled.quest.launcher + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** A reset removes exactly the trees chosen, with their leftovers, and nothing a player keeps. */ +class InstallResetTest { + + private fun tree(root: File, vararg paths: String): File { + for (path in paths) File(root, path).apply { parentFile?.mkdirs(); writeText(path) } + return root + } + + private fun fixture(): Triple { + val base = Files.createTempDirectory("reset").toFile() + val gameRoot = tree( + File(base, "WiiCompiledOpenXRVR"), + "DATA/sys/main.dol", "DATA/files/rel/StaticR.rel", "DATA.extracting/sys/main.dol", "DATA.replaced/x", + "RetroRewind6/Binaries/Code.pul", "RetroRewind6.downloading/version.txt", + "Config.toml", "NAND/shared2/save.bin", "Logs/run/console.log", "Import/game.wcgame", + ) + val games = tree(File(base, "files/game"), "base/libmain.so", "base/game.json", "retro_rewind/libmain.so") + val build = tree(File(base, "files/build"), "toolchain/llvm/bin/clang-21") + return Triple(gameRoot, games, build) + } + + @Test + fun gameFilesTakeTheirLeftoversAndNothingElse() { + val (gameRoot, games, build) = fixture() + val options = InstallReset.Options(gameFiles = true, games = false, modPack = false) + val targets = InstallReset.targets(gameRoot, games, build, options) + assertEquals(listOf("DATA", "DATA.extracting", "DATA.replaced"), targets.map { it.name }) + for (target in targets) InstallReset.deleteTree(target, mutableListOf()) {} + assertFalse(File(gameRoot, "DATA").exists()) + assertFalse(File(gameRoot, "DATA.extracting").exists()) + assertTrue(File(gameRoot, "Config.toml").isFile) + assertTrue(File(gameRoot, "NAND/shared2/save.bin").isFile) + assertTrue(File(gameRoot, "RetroRewind6/Binaries/Code.pul").isFile) + assertTrue(File(games, "base/libmain.so").isFile) + } + + @Test + fun gamesAndPackAreSeparateChoices() { + val (gameRoot, games, build) = fixture() + val both = InstallReset.Options(gameFiles = false, games = true, modPack = true) + assertEquals( + listOf("game", "build", "RetroRewind6", "RetroRewind6.downloading"), + InstallReset.targets(gameRoot, games, build, both).map { it.name }, + ) + val nothing = InstallReset.Options(gameFiles = false, games = false, modPack = false) + assertFalse(nothing.anything) + assertEquals(0, InstallReset.targets(gameRoot, games, build, nothing).size) + } + + @Test + fun countsEveryEntryItWillVisit() { + val (gameRoot, _, _) = fixture() + val data = File(gameRoot, "DATA") + val expected = InstallReset.countEntries(data) + var visited = 0 + InstallReset.deleteTree(data, mutableListOf()) { visited++ } + assertEquals(expected, visited) + assertFalse(data.exists()) + } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroRewindPackTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroRewindPackTest.kt index 6d57638..77c5563 100644 --- a/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroRewindPackTest.kt +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/RetroRewindPackTest.kt @@ -60,6 +60,21 @@ class RetroRewindPackTest { ) } + @Test + fun appliesEachUpdateBeforeItsOwnDeletions() { + val updates = RetroRewindPack.parseUpdates(versions) + val deletions = RetroRewindPack.parseDeletions( + """ + 6.12.6 RetroRewind6/Assets/gone.brres + 6.12.7 RetroRewind6/Race/Course/old.szs + """.trimIndent() + ) + val steps = RetroRewindPack.steps("6.12.5", RetroRewindPack.updatesAfter("6.12.5", updates), deletions) + assertEquals(listOf("6.12.6", "6.12.7"), steps.map { it.first.version }) + assertEquals(listOf("Assets/gone.brres"), steps[0].second) + assertEquals(listOf("Race/Course/old.szs"), steps[1].second) + } + @Test fun keepsOnlyPackPathsAndRefusesOnesThatClimbOut() { assertEquals("Binaries/Code.pul", RetroRewindPack.packRelative("RetroRewind6/Binaries/Code.pul")) diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index 1e8fe87..44b882b 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -261,6 +261,9 @@ void aurora_store_pipeline_caches(); // Allows the pipeline compiler to store the caches itself, rate-limited, whenever a first-use // burst completes. Off by default; enable it while a stall is acceptable, such as in menus. void aurora_set_pipeline_cache_idle_store(bool allowed); +// The Linux thread id of Aurora's frame worker, or 0 before it runs and where there is none. +// Android's OpenXR runtime takes it as a scheduling hint (XR_KHR_android_thread_settings). +uint32_t aurora_get_frame_worker_native_thread_id(void); // Absolute schedule for the next sealed frame, on steady_clock: baseNanos anchors the group and // intervalNanos is the period, so slot k of N+1 fires at base + k*interval/(N+1). Zeros clear it. void aurora_set_present_schedule(uint64_t baseNanos, uint64_t intervalNanos); diff --git a/aurora-main/include/aurora/vulkan_interop.h b/aurora-main/include/aurora/vulkan_interop.h index ac2e34b..59a7c30 100644 --- a/aurora-main/include/aurora/vulkan_interop.h +++ b/aurora-main/include/aurora/vulkan_interop.h @@ -78,11 +78,14 @@ typedef struct { /** * Fired when Aurora either finishes or abandons the stereo sink. `success` * guarantees that the copies were submitted and that every release entry is - * valid. The callback runs on Aurora's frame worker while its queue-submit - * mutex is held: it may record and submit work on the OpenXR side's own Vulkan - * queue, but must not wait for the GPU or re-enter Aurora. + * valid. Otherwise `gpuWorkQueued` says whether the copies may have reached + * Dawn's queue before the failure (the shared buffers may then be written with + * no fence to wait on) or nothing was recorded at all. The callback runs on + * Aurora's frame worker while its queue-submit mutex is held: it may record and + * submit work on the OpenXR side's own Vulkan queue, but must not wait for the + * GPU or re-enter Aurora. */ -typedef void (*AuroraVulkanStereoSubmittedCallback)(uint64_t frameToken, bool success, +typedef void (*AuroraVulkanStereoSubmittedCallback)(uint64_t frameToken, bool success, bool gpuWorkQueued, const AuroraVulkanStereoRelease* releases, uint32_t releaseCount, void* userdata); diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index 8463028..1adef5c 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -27,6 +27,9 @@ #ifdef AURORA_ENABLE_GX #include "gfx/pipeline_cache.hpp" #endif +#if defined(__ANDROID__) +#include +#endif #include "system_info.hpp" #include "tracy/Tracy.hpp" @@ -294,6 +297,8 @@ struct FrameWorkerState { }; FrameWorkerState g_frameWorker; +// The worker's Linux thread id, published for the host's scheduling hints (Android). +std::atomic g_frameWorkerNativeThreadId{0}; bool frame_worker_requested() noexcept { #ifdef AURORA_ENABLE_GX @@ -343,6 +348,9 @@ void frame_worker_main() noexcept { std::lock_guard lock(g_frameWorker.mutex); g_frameWorker.threadId = std::this_thread::get_id(); } +#if defined(__ANDROID__) + g_frameWorkerNativeThreadId.store(static_cast(gettid()), std::memory_order_release); +#endif #ifdef AURORA_ENABLE_GX // Owned by the worker for its whole lifetime so the sealed pass vector and @@ -2524,6 +2532,9 @@ void aurora_set_pipeline_cache_idle_store(bool allowed) { (void)allowed; #endif } +uint32_t aurora_get_frame_worker_native_thread_id(void) { + return aurora::g_frameWorkerNativeThreadId.load(std::memory_order_acquire); +} void aurora_set_present_schedule(uint64_t baseNanos, uint64_t intervalNanos) { aurora::g_presentScheduleBaseNanos.store(baseNanos, std::memory_order_release); aurora::g_presentScheduleIntervalNanos.store(intervalNanos, std::memory_order_release); diff --git a/aurora-main/lib/webgpu/vulkan_interop.cpp b/aurora-main/lib/webgpu/vulkan_interop.cpp index e642cb4..0b41da8 100644 --- a/aurora-main/lib/webgpu/vulkan_interop.cpp +++ b/aurora-main/lib/webgpu/vulkan_interop.cpp @@ -192,7 +192,8 @@ public: m_encoded = true; return true; } - PublishAndClearFrameLocked(frame.frameToken, false); + // Nothing was recorded, so the shared buffers are untouched. + PublishAndClearFrameLocked(frame.frameToken, false, false); return false; } @@ -203,7 +204,7 @@ public: } std::array releases{}; const bool success = EndAccessLocked(releases); - NotifyLocked(frame.frameToken, success, releases); + NotifyLocked(frame.frameToken, success, true, releases); ClearFrameLocked(); } @@ -213,14 +214,15 @@ public: return; } const uint64_t token = m_frameToken; + const bool encoded = m_encoded; std::array releases{}; - if (m_encoded) { + if (encoded) { EndAccessLocked(releases); for (auto& release : releases) { close_fd(release.releaseFenceFd); } } - PublishAndClearFrameLocked(token, false); + PublishAndClearFrameLocked(token, false, encoded); } bool CancelBeforeEncode(uint64_t token) noexcept { @@ -484,20 +486,20 @@ private: m_encoded = false; } - void PublishAndClearFrameLocked(uint64_t token, bool success) noexcept { + void PublishAndClearFrameLocked(uint64_t token, bool success, bool gpuWorkQueued) noexcept { std::array releases{}; for (auto& release : releases) { release = {.releaseFenceFd = -1, .releasedImageLayout = VK_IMAGE_LAYOUT_UNDEFINED}; } - NotifyLocked(token, success, releases); + NotifyLocked(token, success, gpuWorkQueued, releases); ClearFrameLocked(); } - void NotifyLocked(uint64_t token, bool success, + void NotifyLocked(uint64_t token, bool success, bool gpuWorkQueued, const std::array& releases) noexcept { if (m_callback != nullptr) { - m_callback(token, success, releases.data(), m_targetCount, m_userdata); + m_callback(token, success, gpuWorkQueued, releases.data(), m_targetCount, m_userdata); } else { for (auto release : releases) { close_fd(release.releaseFenceFd); diff --git a/docs/quest-port.md b/docs/quest-port.md index 867dec4..d7ada34 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -76,6 +76,18 @@ session is therefore impossible without a patched Dawn. Instead: 5. The copy runs on the queue bound to the session before `xrReleaseSwapchainImage`, so the compositor sees ordinary same-queue work. +Dawn's release fences are imported into semaphores owned by the copy's +submission slot, not by the shared buffer: importing into a semaphore whose +previous wait is still pending is invalid, and only the slot's fence proves +that wait completed before the semaphore is reused. Each frame hands Dawn a +duplicate of the buffer's copy-out fence, so a frame cancelled before encoding +keeps the ordering against the last real reader. A copy that never reached the +queue (a submit refused for memory, Aurora failing before it recorded anything) +ends its frame on the retained layer with the `VkResult` in the log, and the +next frame is tried; only work that may have been queued with no completion +marker, a lost device above all, ends the session. Three hundred skipped copies +in a row end it too. + The cost is one extra GPU copy per eye per frame, a few hundred microseconds at Quest eye resolutions; the benefit is that stock Dawn is used unchanged and the OpenXR device outlives Aurora's, which is exactly the failure DolphinXR @@ -103,7 +115,11 @@ suggested for `oculus/touch_controller` and `khr/simple_controller`. - `runtime/src/vr/openxr_android.cpp`: `xrInitializeLoaderKHR` with the JavaVM and activity SDL already holds, the `XrInstanceCreateInfoAndroidKHR` - chain (`OpenXRConfig::instance_create_next`), and the optional thread hint. + chain (`OpenXRConfig::instance_create_next`), and the `XR_KHR_android_thread_settings` + hints: the game thread (SDL's main thread, which carries the guest fibers) as + application main, Aurora's frame worker as renderer main and the pacing + thread as renderer worker, so the runtime keeps the two busy threads on the + fast cores. The log says which hints the runtime accepted. - `runtime/src/platform/host_platform.cpp` / `runtime_config.h`: the activity exports `MKW_ANDROID_DATA_DIR` (external files dir, user reachable) and `MKW_ANDROID_RESOURCES_DIR` (unpacked `wii_bootstrap/`, `dsp_coef.bin`, @@ -324,6 +340,13 @@ on Home. Home's main button is always the next step: **Select disc image** while there are no game files, **Build on this Quest** once they are there and no game is installed (or the installed one is stale), and **Play** once both are present. **Import from computer** sits beside the first two. +**Reset installation** leads instead when the game files are there but unusable, or when an +attempt to set them up failed and left none that work; a failed attempt over working files offers +it as the second button, and Settings → Other always has it. It removes the game files with any +unfinished extraction or import, and on request the built games with the on-device build +workspace and the Retro Rewind pack; Config.toml, the saves and the logs stay (`InstallReset`, a +`GameSetup` task like the others, with progress and cancel). Nothing that replaces files the game +reads, a reset included, starts while the game process is alive. ### Building the game on the headset diff --git a/runtime/include/aurora_events.h b/runtime/include/aurora_events.h index 403a9ad..0b42b00 100644 --- a/runtime/include/aurora_events.h +++ b/runtime/include/aurora_events.h @@ -90,6 +90,9 @@ inline void ProcessAuroraEvents(const AuroraEvent* events) { } break; case AURORA_WINDOW_RESIZED: +#if !defined(__ANDROID__) + // The Quest's surface is pinned to a size nobody sees; persisting it would only churn + // Config.toml on every quit. if (aurora_get_display_mode() == AURORA_DISPLAY_MODE_WINDOWED && event->windowSize.width != 0 && event->windowSize.height != 0) { WindowPlacementPersistence::width = event->windowSize.width; @@ -97,6 +100,7 @@ inline void ProcessAuroraEvents(const AuroraEvent* events) { WindowPlacementPersistence::sizeDirty = true; WindowPlacementPersistence::changedAt = std::chrono::steady_clock::now(); } +#endif surfaceChanged = true; break; case AURORA_DISPLAY_SCALE_CHANGED: diff --git a/runtime/include/vr/openxr_android.h b/runtime/include/vr/openxr_android.h index b46098e..9835015 100644 --- a/runtime/include/vr/openxr_android.h +++ b/runtime/include/vr/openxr_android.h @@ -35,6 +35,8 @@ enum class OpenXRAndroidThreadType { RendererWorker, }; bool OpenXRAndroidRegisterThread(OpenXRRuntime& runtime, OpenXRAndroidThreadType type); +// The same hint for another thread, named by its Linux thread id (gettid). +bool OpenXRAndroidRegisterThreadId(OpenXRRuntime& runtime, OpenXRAndroidThreadType type, uint32_t thread_id); } // namespace mkw::vr diff --git a/runtime/include/vr/openxr_backend.h b/runtime/include/vr/openxr_backend.h index a4e1d2d..0684649 100644 --- a/runtime/include/vr/openxr_backend.h +++ b/runtime/include/vr/openxr_backend.h @@ -32,7 +32,12 @@ enum class OpenXRBeginStatus { enum class OpenXRSubmissionStatus { Success, + // GPU work may have touched the compositor image or the shared buffers with no completion + // marker to wait on; the session cannot continue. Failed, + // The eye copy was not submitted and nothing touched the compositor image or the shared + // buffers, so the frame may end without a layer and the next one is tried normally. + Skipped, Timeout, ShuttingDown, }; diff --git a/runtime/src/vr/openxr_android.cpp b/runtime/src/vr/openxr_android.cpp index a88659a..ec8e803 100644 --- a/runtime/src/vr/openxr_android.cpp +++ b/runtime/src/vr/openxr_android.cpp @@ -122,6 +122,10 @@ const void* OpenXRAndroidInstanceCreateNext() { } bool OpenXRAndroidRegisterThread(OpenXRRuntime& runtime, OpenXRAndroidThreadType type) { + return OpenXRAndroidRegisterThreadId(runtime, type, static_cast(syscall(SYS_gettid))); +} + +bool OpenXRAndroidRegisterThreadId(OpenXRRuntime& runtime, OpenXRAndroidThreadType type, uint32_t thread_id) { if (!runtime.HasSession()) { return false; } @@ -155,7 +159,6 @@ bool OpenXRAndroidRegisterThread(OpenXRRuntime& runtime, OpenXRAndroidThreadType xr_type = XR_ANDROID_THREAD_TYPE_RENDERER_WORKER_KHR; break; } - const auto thread_id = static_cast(syscall(SYS_gettid)); XrResult result = set_thread(runtime.Session(), xr_type, thread_id); if (XR_FAILED(result) && type == OpenXRAndroidThreadType::RendererWorker) { // Some Quest runtime builds advertise the extension but reject the diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index d3ed506..76e192e 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -39,6 +39,7 @@ #include "vr/openxr_android.h" #include "vr/openxr_vulkan.h" #include +#include #define XR_USE_TIMESPEC #include #define MKW_OPENXR_GRAPHICS_BACKEND 1 @@ -434,6 +435,10 @@ public: input_.reset(); } +#if defined(__ANDROID__) + // The producer: SDL's main thread, which also carries every guest fiber. + game_thread_id_ = static_cast(gettid()); +#endif stop_.store(false, std::memory_order_release); { std::lock_guard lock(interpolation_mutex_); @@ -558,6 +563,9 @@ private: #else static constexpr AuroraBackend kRequiredAuroraBackend = BACKEND_VULKAN; #endif + // Skipped eye copies tolerated back to back before the session is given up: a few seconds + // at the headset's refresh rate. + static constexpr uint32_t kMaxConsecutiveSkips = 300; bool BackendMatchesConfiguredGraphicsApi(const AuroraConfig& aurora_config) { if (aurora_config.desiredBackend == BACKEND_AUTO || @@ -612,6 +620,21 @@ private: return true; } +#if defined(__ANDROID__) + // Aurora's frame worker publishes its native thread id once it runs; until then there is + // nothing to hint. The hint itself may be refused by the runtime, which is only logged. + bool RegisterAuroraFrameWorkerThread() { + const uint32_t thread_id = aurora_get_frame_worker_native_thread_id(); + if (thread_id == 0 || runtime_ == nullptr) { + return false; + } + const bool hinted = OpenXRAndroidRegisterThreadId(*runtime_, OpenXRAndroidThreadType::RendererMain, thread_id); + RT_LOG(RT_TAG_RUNTIME) << "OpenXR: Android thread hint for Aurora's frame worker " + << (hinted ? "set" : "refused") << std::endl; + return true; + } +#endif + static bool ProvideStereoFrame(uint32_t, AuroraStereoFrame* output, void* userdata) { auto* self = static_cast(userdata); if (self == nullptr || output == nullptr) { @@ -632,11 +655,25 @@ private: void PacingThread() noexcept { #if defined(__ANDROID__) + // The runtime schedules hinted threads onto the fast cores. The game thread and Aurora's + // frame worker, which submits the GPU work, are the ones that matter; this thread only + // paces. + bool worker_registered = false; if (runtime_ != nullptr) { - OpenXRAndroidRegisterThread(*runtime_, OpenXRAndroidThreadType::RendererMain); + const bool pacing_hinted = + OpenXRAndroidRegisterThread(*runtime_, OpenXRAndroidThreadType::RendererWorker); + bool game_hinted = false; + if (game_thread_id_ != 0) { + game_hinted = OpenXRAndroidRegisterThreadId(*runtime_, OpenXRAndroidThreadType::ApplicationMain, + game_thread_id_); + } + RT_LOG(RT_TAG_RUNTIME) << "OpenXR: Android thread hints: game " << (game_hinted ? "set" : "refused") + << ", pacing " << (pacing_hinted ? "set" : "refused") << std::endl; + worker_registered = RegisterAuroraFrameWorkerThread(); } #endif bool fatal = false; + uint32_t consecutive_skips = 0; bool store_gate_set = false; bool store_gate_immersive = false; bool presentation_logged = false; @@ -644,6 +681,11 @@ private: uint32_t presentation_log_count = 0; bool immersive_submission_logged = false; while (!stop_.load(std::memory_order_acquire) && !fatal) { +#if defined(__ANDROID__) + if (!worker_registered) { + worker_registered = RegisterAuroraFrameWorkerThread(); + } +#endif const OpenXREventStatus events = runtime_->PollEvents(); const bool session_active = runtime_->IsSessionRunning(); MkwVRPolicySetSessionActive(session_active); @@ -842,11 +884,26 @@ private: if (!backend_->FinishFrame(frame, submit)) { SetError(backend_->LastError()); fatal = true; + } else if (submission == OpenXRSubmissionStatus::Skipped) { + // No GPU work touched the compositor image or the shared buffers, so the frame + // ended on the retained layer and the next one is tried normally. A long run of + // skips means the copy path is broken for good. + ++consecutive_skips; + if (consecutive_skips == 1 || consecutive_skips % 60 == 0) { + RT_LOG(RT_TAG_RUNTIME) << "OpenXR: eye copy skipped (" << consecutive_skips + << " in a row): " << backend_->LastError() << std::endl; + } + if (consecutive_skips >= kMaxConsecutiveSkips) { + SetError(std::string("Aurora's ") + kGraphicsBackendName + + " stereo copy keeps failing; continuing on the mirror output"); + fatal = true; + } } else if (!submit) { SetError(std::string("Aurora's ") + kGraphicsBackendName + " stereo copy failed; continuing on the mirror output"); fatal = true; } else { + consecutive_skips = 0; ++timing_submissions_; } if (submit && !fatal && immersive && !immersive_submission_logged) { @@ -1262,6 +1319,7 @@ private: bool prepared_ = false; bool provider_registered_ = false; bool graphics_retained_ = false; + uint32_t game_thread_id_ = 0; }; #endif // MKW_OPENXR_GRAPHICS_BACKEND diff --git a/runtime/src/vr/openxr_vulkan.cpp b/runtime/src/vr/openxr_vulkan.cpp index b9af2d0..8da3592 100644 --- a/runtime/src/vr/openxr_vulkan.cpp +++ b/runtime/src/vr/openxr_vulkan.cpp @@ -105,6 +105,16 @@ void CloseFd(int& fd) noexcept { fd = -1; } +int DupFd(int fd) noexcept { + return fd >= 0 ? ::dup(fd) : -1; +} + +std::string VkFailure(const char* what, VkResult result) { + std::ostringstream message; + message << what << " (VkResult " << static_cast(result) << ')'; + return message.str(); +} + } // namespace class OpenXRVulkanBackend::Impl final { @@ -131,11 +141,11 @@ public: VkImage image = VK_NULL_HANDLE; VkDeviceMemory memory = VK_NULL_HANDLE; VkFormat format = VK_FORMAT_UNDEFINED; - // Waits on Dawn's release fence (temporary sync-fd import). - VkSemaphore wait_semaphore = VK_NULL_HANDLE; // Signalled by this device's copy and exported as the fence Dawn waits on. VkSemaphore signal_semaphore = VK_NULL_HANDLE; VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED; + // The sync fd of this slot's last copy-out, kept until the next copy replaces it. Dawn + // gets a duplicate per frame, so a frame cancelled before encoding cannot lose it. int pending_acquire_fd = -1; uint32_t width = 0; uint32_t height = 0; @@ -144,9 +154,21 @@ public: struct Submission { VkFence fence = VK_NULL_HANDLE; VkCommandBuffer command_buffer = VK_NULL_HANDLE; + // Dawn's release fences are imported here, one semaphore per eye. They belong to the + // submission rather than the slot: importing into a semaphore whose previous wait is + // still pending is invalid, and only the submission's fence proves that wait completed. + std::array wait_semaphores{}; bool busy = false; }; + enum class CopyOutcome { + Submitted, + // Nothing reached the queue: the shared buffers and the compositor image are untouched. + Skipped, + // Work may have been queued with no completion marker to wait on. + Unsafe, + }; + struct PendingCopy { uint64_t token = 0; uint32_t slot = 0; @@ -382,7 +404,7 @@ public: eye_slot.width, eye_slot.height, static_cast(aurora_format_), - eye_slot.pending_acquire_fd, + DupFd(eye_slot.pending_acquire_fd), static_cast(eye_slot.layout), }; } @@ -401,6 +423,10 @@ public: submission_unsafe_ = false; } if (!aurora_vulkan_set_stereo_targets(frame.xr_frame.serial, targets.data(), target_count)) { + // The duplicates were not taken; the slots keep their own descriptors. + for (uint32_t eye = 0; eye < target_count; ++eye) { + CloseFd(targets[eye].acquireFenceFd); + } { std::lock_guard lock(submission_mutex_); awaiting_token_ = 0; @@ -410,13 +436,6 @@ public: EndActiveFrameWithoutLayers(frame.xr_frame); return OpenXRBeginStatus::Error; } - { - // Aurora now owns the acquire descriptors it accepted. - std::lock_guard lock(vk_mutex_); - for (uint32_t eye = 0; eye < target_count; ++eye) { - slots_[eye][slot].pending_acquire_fd = -1; - } - } next_slot_ = (slot + 1) % kSlotCount; frame.expects_gpu_submission = true; return OpenXRBeginStatus::Ready; @@ -439,8 +458,10 @@ public: if (shutting_down_) { return OpenXRSubmissionStatus::ShuttingDown; } - return submission_success_ ? OpenXRSubmissionStatus::Success - : OpenXRSubmissionStatus::Failed; + if (submission_success_) { + return OpenXRSubmissionStatus::Success; + } + return submission_unsafe_ ? OpenXRSubmissionStatus::Failed : OpenXRSubmissionStatus::Skipped; } bool TryCancelPendingFrame(OpenXRBackendFrame& frame) { @@ -914,6 +935,12 @@ private: vkCreateFence(vk_device_, &fence_info, nullptr, &submission.fence) != VK_SUCCESS) { return Fail("could not allocate the OpenXR copy command buffers"); } + VkSemaphoreCreateInfo semaphore_info{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + for (VkSemaphore& semaphore : submission.wait_semaphores) { + if (vkCreateSemaphore(vk_device_, &semaphore_info, nullptr, &semaphore) != VK_SUCCESS) { + return Fail("vkCreateSemaphore failed for an eye copy wait semaphore"); + } + } } return true; } @@ -926,6 +953,11 @@ private: if (submission.fence != VK_NULL_HANDLE) { vkDestroyFence(vk_device_, submission.fence, nullptr); } + for (VkSemaphore semaphore : submission.wait_semaphores) { + if (semaphore != VK_NULL_HANDLE) { + vkDestroySemaphore(vk_device_, semaphore, nullptr); + } + } submission = {}; } if (command_pool_ != VK_NULL_HANDLE) { @@ -1036,10 +1068,6 @@ private: return Fail("vkBindImageMemory failed for an imported eye buffer"); } - VkSemaphoreCreateInfo semaphore_info{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; - if (vkCreateSemaphore(vk_device_, &semaphore_info, nullptr, &slot.wait_semaphore) != VK_SUCCESS) { - return Fail("vkCreateSemaphore failed for the eye wait semaphore"); - } VkExportSemaphoreCreateInfo export_info{VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO}; export_info.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT; VkSemaphoreCreateInfo exportable{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; @@ -1064,9 +1092,6 @@ private: if (slot.signal_semaphore != VK_NULL_HANDLE) { vkDestroySemaphore(vk_device_, slot.signal_semaphore, nullptr); } - if (slot.wait_semaphore != VK_NULL_HANDLE) { - vkDestroySemaphore(vk_device_, slot.wait_semaphore, nullptr); - } if (slot.image != VK_NULL_HANDLE) { vkDestroyImage(vk_device_, slot.image, nullptr); } @@ -1084,7 +1109,7 @@ private: // ---- The copy into the compositor image ----------------------------------- - static void OnAuroraSubmitted(uint64_t token, bool success, + static void OnAuroraSubmitted(uint64_t token, bool success, bool gpu_work_queued, const AuroraVulkanStereoRelease* releases, uint32_t release_count, void* userdata) { auto* self = static_cast(userdata); @@ -1102,7 +1127,7 @@ private: return; } - bool copied = false; + CopyOutcome outcome = CopyOutcome::Skipped; { std::lock_guard lock(self->vk_mutex_); bool expected = false; @@ -1111,11 +1136,16 @@ private: expected = token == self->awaiting_token_ && token == self->pending_copy_.token; } if (expected && success && release_count >= self->pending_copy_.target_count) { - copied = self->RecordAndSubmitCopyLocked(owned); + outcome = self->RecordAndSubmitCopyLocked(owned); } else { for (auto& release : owned) { CloseFd(release.releaseFenceFd); } + // Aurora failing after it queued GPU work may have written the shared buffer + // with no completion marker to wait on; failing before that touched nothing. + if (expected && !success && gpu_work_queued) { + outcome = CopyOutcome::Unsafe; + } } if (!expected) { return; @@ -1127,33 +1157,34 @@ private: return; } self->submitted_token_ = token; - self->submission_success_ = copied; + self->submission_success_ = outcome == CopyOutcome::Submitted; self->submission_arrived_ = true; - // A failed Aurora submission may have queued a write into the shared - // buffer; a failed copy here may have queued a write into the XR - // image. Neither has a trustworthy completion marker. - self->submission_unsafe_ = !copied; + self->submission_unsafe_ = outcome == CopyOutcome::Unsafe; } self->submission_cv_.notify_all(); } - bool RecordAndSubmitCopyLocked(std::array& releases) { - Submission* submission = AcquireSubmissionLocked(); - if (submission == nullptr) { + CopyOutcome RecordAndSubmitCopyLocked(std::array& releases) { + const auto close_releases = [&releases] { for (auto& release : releases) { CloseFd(release.releaseFenceFd); } - return false; + }; + CopyOutcome acquire_failure = CopyOutcome::Skipped; + Submission* submission = AcquireSubmissionLocked(acquire_failure); + if (submission == nullptr) { + close_releases(); + return acquire_failure; } const PendingCopy copy = pending_copy_; VkCommandBuffer cmd = submission->command_buffer; VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - if (vkBeginCommandBuffer(cmd, &begin) != VK_SUCCESS) { - for (auto& release : releases) { - CloseFd(release.releaseFenceFd); - } - return Fail("vkBeginCommandBuffer failed for the eye copy"); + const VkResult begun = vkBeginCommandBuffer(cmd, &begin); + if (begun != VK_SUCCESS) { + close_releases(); + Fail(VkFailure("vkBeginCommandBuffer failed for the eye copy", begun)); + return CopyOutcome::Skipped; } std::array waits{}; @@ -1168,21 +1199,24 @@ private: AuroraVulkanStereoRelease& release = releases[eye]; if (release.releaseFenceFd >= 0) { + const VkSemaphore wait_semaphore = submission->wait_semaphores[eye]; VkImportSemaphoreFdInfoKHR import{VK_STRUCTURE_TYPE_IMPORT_SEMAPHORE_FD_INFO_KHR}; - import.semaphore = slot.wait_semaphore; + import.semaphore = wait_semaphore; import.flags = VK_SEMAPHORE_IMPORT_TEMPORARY_BIT; import.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT; import.fd = release.releaseFenceFd; - if (pfn_import_semaphore_fd_(vk_device_, &import) == VK_SUCCESS) { + const VkResult imported = pfn_import_semaphore_fd_(vk_device_, &import); + if (imported == VK_SUCCESS) { // Ownership of the descriptor moved to Vulkan. release.releaseFenceFd = -1; - waits[wait_count] = slot.wait_semaphore; + waits[wait_count] = wait_semaphore; wait_stages[wait_count] = VK_PIPELINE_STAGE_TRANSFER_BIT; ++wait_count; } else { - CloseFd(release.releaseFenceFd); + close_releases(); vkEndCommandBuffer(cmd); - return Fail("vkImportSemaphoreFdKHR rejected Dawn's release fence"); + Fail(VkFailure("vkImportSemaphoreFdKHR rejected Dawn's release fence", imported)); + return CopyOutcome::Skipped; } } @@ -1260,8 +1294,10 @@ private: slot.layout = VK_IMAGE_LAYOUT_GENERAL; signals[signal_count++] = slot.signal_semaphore; } - if (vkEndCommandBuffer(cmd) != VK_SUCCESS) { - return Fail("vkEndCommandBuffer failed for the eye copy"); + const VkResult ended = vkEndCommandBuffer(cmd); + if (ended != VK_SUCCESS) { + Fail(VkFailure("vkEndCommandBuffer failed for the eye copy", ended)); + return CopyOutcome::Skipped; } VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO}; @@ -1272,8 +1308,12 @@ private: submit.pCommandBuffers = &cmd; submit.signalSemaphoreCount = signal_count; submit.pSignalSemaphores = signals.data(); - if (vkQueueSubmit(vk_queue_, 1, &submit, submission->fence) != VK_SUCCESS) { - return Fail("vkQueueSubmit failed for the eye copy"); + const VkResult submitted = vkQueueSubmit(vk_queue_, 1, &submit, submission->fence); + if (submitted != VK_SUCCESS) { + Fail(VkFailure("vkQueueSubmit failed for the eye copy", submitted)); + // A memory failure leaves every referenced resource untouched, so the frame merely + // has no copy; only a lost device leaves the queue's state unknown. + return submitted == VK_ERROR_DEVICE_LOST ? CopyOutcome::Unsafe : CopyOutcome::Skipped; } submission->busy = true; @@ -1296,22 +1336,27 @@ private: Log(OpenXRLogLevel::Warning, "vkGetSemaphoreFdKHR failed; the eye copy was waited on the CPU"); } } - return true; + return CopyOutcome::Submitted; } - Submission* AcquireSubmissionLocked() { + Submission* AcquireSubmissionLocked(CopyOutcome& failure) { Submission& submission = submissions_[next_submission_]; next_submission_ = (next_submission_ + 1) % kSubmissionRingSize; if (submission.busy) { - if (vkWaitForFences(vk_device_, 1, &submission.fence, VK_TRUE, kFenceTimeoutNanos) != VK_SUCCESS) { - Fail("a previous eye copy did not complete in time"); + const VkResult waited = vkWaitForFences(vk_device_, 1, &submission.fence, VK_TRUE, kFenceTimeoutNanos); + if (waited != VK_SUCCESS) { + // An older copy is still outstanding, so the queue's state is unknown. + failure = CopyOutcome::Unsafe; + Fail(VkFailure("a previous eye copy did not complete in time", waited)); return nullptr; } submission.busy = false; } vkResetFences(vk_device_, 1, &submission.fence); - if (vkResetCommandBuffer(submission.command_buffer, 0) != VK_SUCCESS) { - Fail("vkResetCommandBuffer failed"); + const VkResult reset = vkResetCommandBuffer(submission.command_buffer, 0); + if (reset != VK_SUCCESS) { + failure = CopyOutcome::Skipped; + Fail(VkFailure("vkResetCommandBuffer failed for the eye copy", reset)); return nullptr; } return &submission;