- Added new strings for installation reset prompts and messages in strings.xml.

- Created InstallReset.kt to handle the removal of game files, built games, and mod packs while preserving user data.
- Implemented unit tests for InstallReset to ensure correct functionality and file handling.
- Updated RetroRewindPackTest to include new test cases for update and deletion order.
- Enhanced Vulkan interop and OpenXR integration to support new thread scheduling hints for Android.
- Improved error handling and logging for GPU submission failures in Vulkan backend.
This commit is contained in:
iChris4 committed 2026-09-19 00:08:58 +02:00
1 parent d4a3f0b350
commit 7f2113dbc5
22 files changed
+583 -94

No files matched your search

+3 -1
View File
@@ -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.
@@ -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)
@@ -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) {
@@ -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)
@@ -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<File>()
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<File> {
val roots = mutableListOf<File>()
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<File> =
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<File>, onEntry: () -> Unit) {
for (entry in root.walkBottomUp()) {
onEntry()
if (!entry.delete() && entry.exists()) kept += entry
}
}
}
@@ -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() }
@@ -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<Update>, deletions: List<Deletion>): List<Pair<Update, List<String>>> {
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)
@@ -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
@@ -77,6 +77,23 @@
<string name="home_replace_title">Replace the game files?</string>
<string name="home_replace_message">Your current DATA folder stays until the new disc image has been checked and completely extracted.</string>
<string name="home_launch_failed">The game could not be started.</string>
<string name="home_close_game_first">Close the game first.</string>
<string name="home_reset">Reset installation</string>
<string name="home_reset_title">Reset the installation?</string>
<string name="home_reset_message">Removes what you tick below so it can be set up again from scratch. Your saves and settings stay.</string>
<string name="home_reset_option_game_files">Game files (DATA), with any unfinished extraction or import</string>
<string name="home_reset_option_games">Built games and the build workspace</string>
<string name="home_reset_option_mod_pack">Retro Rewind pack (RetroRewind6)</string>
<string name="home_resetting">Resetting %1$d%%</string>
<string name="home_reset_status">Counting the files to remove.</string>
<string name="home_reset_progress">%1$d of %2$d files removed</string>
<string name="home_reset_running">Removing the files you chose. The launcher stays usable meanwhile.</string>
<string name="home_reset_done">The installation was reset. Select your disc image to set the game up again.</string>
<string name="home_reset_cancelled">Reset cancelled. What was already removed stays removed.</string>
<string name="home_reset_failed">The installation could not be reset completely.\n%1$s</string>
<string name="about_reset">Reset installation</string>
<string name="about_reset_helper">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.</string>
<string name="game_storage_failed">The game folder could not be prepared: %1$s</string>
<!-- Settings shell -->
<string name="settings_tab_vr">VR</string>
@@ -230,6 +247,7 @@
<string name="disc_setup_channel">Game file setup</string>
<string name="disc_setup_notification_title">Setting up the game files</string>
<string name="disc_setup_checking">Checking the disc image…</string>
<string name="disc_setup_resetting">Resetting the installation: %1$d%%</string>
<string name="disc_setup_extracting">Extracting: %1$d%%</string>
<string name="disc_setup_building">Building: %1$d%%</string>
<string name="build_notification_title">Building the game</string>
@@ -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<File, File, File> {
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())
}
}
@@ -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"))
+3
View File
@@ -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);
+7 -4
View File
@@ -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);
+11
View File
@@ -27,6 +27,9 @@
#ifdef AURORA_ENABLE_GX
#include "gfx/pipeline_cache.hpp"
#endif
#if defined(__ANDROID__)
#include <unistd.h>
#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<uint32_t> 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<uint32_t>(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);
+10 -8
View File
@@ -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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS>&
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);
+24 -1
View File
@@ -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
+4
View File
@@ -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:
+2
View File
@@ -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
+5
View File
@@ -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,
};
+4 -1
View File
@@ -122,6 +122,10 @@ const void* OpenXRAndroidInstanceCreateNext() {
}
bool OpenXRAndroidRegisterThread(OpenXRRuntime& runtime, OpenXRAndroidThreadType type) {
return OpenXRAndroidRegisterThreadId(runtime, type, static_cast<uint32_t>(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<uint32_t>(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
+59 -1
View File
@@ -39,6 +39,7 @@
#include "vr/openxr_android.h"
#include "vr/openxr_vulkan.h"
#include <time.h>
#include <unistd.h>
#define XR_USE_TIMESPEC
#include <openxr/openxr_platform.h>
#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<uint32_t>(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<OpenXRIntegration*>(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
+96 -51
View File
@@ -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<int32_t>(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<VkSemaphore, kOpenXREyeCount> 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<int64_t>(aurora_format_),
eye_slot.pending_acquire_fd,
DupFd(eye_slot.pending_acquire_fd),
static_cast<int32_t>(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<Impl*>(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<AuroraVulkanStereoRelease, kOpenXREyeCount>& releases) {
Submission* submission = AcquireSubmissionLocked();
if (submission == nullptr) {
CopyOutcome RecordAndSubmitCopyLocked(std::array<AuroraVulkanStereoRelease, kOpenXREyeCount>& 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<VkSemaphore, kOpenXREyeCount> 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;