mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 01:00:14 +02:00
- 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:
1 parent
d4a3f0b350
commit
7f2113dbc5
22 files changed
+583
-94
No files matched your search
+3
-1
@@ -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"))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user