mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 05:00:27 +02:00
Updated Quest Launcher & Added Retro Rewind Automatic Download
This commit is contained in:
1 parent
ee5df403ac
commit
d2fa3789b0
14 files changed
+578
-72
No files matched your search
@@ -22,11 +22,14 @@ each VR launch, preserving other preferences. Its portable configuration lives a
|
||||
`RecompVR/UserData/Config.toml` beneath WheelWizard's data folder. Normal graphics settings remain
|
||||
in `Recomp/UserData/Config.toml`. Both backends use the normal installation's effective NAND.
|
||||
|
||||
Standalone launches remain opt-in. `Config.toml` is created with the following defaults:
|
||||
Standalone launches start in VR too: this is the VR build, and `required = false` makes a failed
|
||||
headset startup fall back to the desktop renderer rather than stop the game. `Config.toml` is
|
||||
created with the following defaults, and a configuration that never mentions `enabled` reads the
|
||||
same way:
|
||||
|
||||
```toml
|
||||
[vr]
|
||||
enabled = false
|
||||
enabled = true
|
||||
required = false
|
||||
mirror_view = "normal"
|
||||
controller_mode = "wii_remote"
|
||||
@@ -48,9 +51,9 @@ first_person_hidden_model = 0
|
||||
first_person_rotation = "yaw"
|
||||
```
|
||||
|
||||
Set `enabled = true`, close the game completely, and start it again. These settings are read only
|
||||
at launch. The in-game F10 settings bar also exposes the enable switch, but a restart is still
|
||||
required.
|
||||
To play this installation on the desktop instead, set `enabled = false`, close the game completely,
|
||||
and start it again. These settings are read only at launch. The in-game F10 settings bar also
|
||||
exposes the enable switch, but a restart is still required.
|
||||
|
||||
`required = false` is the safe default: an absent runtime, disconnected headset, unsupported GPU,
|
||||
or graphics-binding failure is logged and the game continues in ordinary desktop mode. A temporary
|
||||
|
||||
+6
-3
@@ -26,8 +26,10 @@ powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product ba
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install
|
||||
```
|
||||
|
||||
`-Mod` puts the Retro Rewind pack in the package, which is how the headset gets
|
||||
it; **Import from computer** takes the same `.wcgame` from the Import folder.
|
||||
`-Mod` puts the Retro Rewind pack in the package; **Import from computer** takes
|
||||
the same `.wcgame` from the Import folder. The headset can also fetch that pack
|
||||
itself with **Download Retro Rewind** on Home, from Retro Rewind's own
|
||||
distribution server, the way the PC launcher does.
|
||||
Copy your disc image to the headset (for example `adb push MarioKart.iso
|
||||
/sdcard/Download/`) and press **Select disc image** in the launcher, which checks
|
||||
and extracts it with nod, as the PC installer does. Or push an already extracted
|
||||
@@ -43,6 +45,7 @@ The app opens on a 2D launcher panel modelled on the PC launcher (WheelWizard VR
|
||||
**Home** picks the game with a toggle, starts it in the headset and says what is
|
||||
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) and lists both games. The game itself is `QuestActivity`, in its own
|
||||
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
|
||||
`:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity`
|
||||
still starts it directly.
|
||||
@@ -17,13 +17,14 @@ import org.wiicompiled.quest.GameProfile
|
||||
* - [Task.ImportPackage]: a .wcgame built on a PC becomes the game library, and DATA too when
|
||||
* 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]).
|
||||
*
|
||||
* 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 }
|
||||
enum class Task { ExtractDisc, ImportPackage, BuildGame, DownloadModPack }
|
||||
|
||||
sealed interface State {
|
||||
data object Idle : State
|
||||
@@ -101,6 +102,8 @@ object GameSetup {
|
||||
!cancelRequested
|
||||
}
|
||||
GameBuild.run(context, profile, reporter, cancelled = { cancelRequested }, finishing)
|
||||
} else if (task == Task.DownloadModPack) {
|
||||
RetroRewindPack.run(context, progress, cancelled = { cancelRequested }, finishing)
|
||||
} else {
|
||||
uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor ->
|
||||
when (task) {
|
||||
|
||||
@@ -42,7 +42,8 @@ class GameSetupService : Service() {
|
||||
?: GameSetup.Task.ExtractDisc
|
||||
startForeground(NOTIFICATION_ID, notification(GameSetup.State.Checking(task)), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
val uri = intent?.data
|
||||
if ((uri == null && task != GameSetup.Task.BuildGame) || !GameSetup.begin(task)) {
|
||||
val needsUri = task != GameSetup.Task.BuildGame && task != GameSetup.Task.DownloadModPack
|
||||
if ((uri == null && needsUri) || !GameSetup.begin(task)) {
|
||||
// A task already going keeps the service, and ends it itself.
|
||||
if (!GameSetup.isRunning) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
@@ -103,6 +104,7 @@ class GameSetupService : Service() {
|
||||
GameSetup.Task.ImportPackage -> R.string.disc_setup_importing
|
||||
GameSetup.Task.BuildGame -> R.string.disc_setup_building
|
||||
GameSetup.Task.ExtractDisc -> R.string.disc_setup_extracting
|
||||
GameSetup.Task.DownloadModPack -> R.string.mod_pack_downloading
|
||||
}
|
||||
builder.setContentText(getString(text, percent)).setProgress(100, percent, false)
|
||||
} else {
|
||||
@@ -123,6 +125,13 @@ class GameSetupService : Service() {
|
||||
private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000
|
||||
private const val BUILD_WAKE_LOCK_TIMEOUT_MS = 3L * 60 * 60 * 1000
|
||||
|
||||
/** Downloads or updates the Retro Rewind pack from its own server ([RetroRewindPack]). */
|
||||
fun startModPackDownload(context: Context) {
|
||||
val intent = Intent(context, GameSetupService::class.java)
|
||||
.putExtra(EXTRA_TASK, GameSetup.Task.DownloadModPack.name)
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.content.res.ColorStateList
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -42,7 +43,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 }
|
||||
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack }
|
||||
|
||||
private lateinit var navHome: View
|
||||
private lateinit var navSettings: View
|
||||
@@ -107,6 +108,9 @@ class LauncherActivity : Activity() {
|
||||
dataBanner = findViewById(R.id.home_data_banner)
|
||||
dataBannerIcon = findViewById(R.id.home_data_banner_icon)
|
||||
dataBannerText = findViewById(R.id.home_data_banner_text)
|
||||
// The banner floats over the page, so a long message scrolls inside it rather than growing
|
||||
// over the buttons underneath.
|
||||
dataBannerText.movementMethod = ScrollingMovementMethod()
|
||||
|
||||
homeTitle = findViewById(R.id.home_title)
|
||||
gameToggle = findViewById(R.id.home_game_toggle)
|
||||
@@ -121,6 +125,7 @@ class LauncherActivity : Activity() {
|
||||
selectDiscImage = ::selectDiscImage,
|
||||
importGame = ::importGame,
|
||||
buildGame = ::buildGame,
|
||||
downloadModPack = ::downloadModPack,
|
||||
)
|
||||
savedInstanceState?.getString(KEY_TAB)?.let { name ->
|
||||
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
|
||||
@@ -220,12 +225,15 @@ class LauncherActivity : Activity() {
|
||||
val task = setupTask(setup)
|
||||
val importing = task == GameSetup.Task.ImportPackage
|
||||
val building = task == GameSetup.Task.BuildGame
|
||||
val downloadingPack = task == GameSetup.Task.DownloadModPack
|
||||
|
||||
// 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.
|
||||
mainAction = when {
|
||||
running -> Action.Resume
|
||||
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
|
||||
!GameStorage.modContentReady(this, profile) -> Action.DownloadModPack
|
||||
gameStatus != GameLibrary.Status.Ready -> Action.BuildGame
|
||||
else -> Action.Play
|
||||
}
|
||||
@@ -250,6 +258,7 @@ class LauncherActivity : Activity() {
|
||||
when {
|
||||
building -> R.string.home_building
|
||||
importing -> R.string.home_importing
|
||||
downloadingPack -> R.string.home_mod_pack_downloading
|
||||
else -> R.string.home_extracting
|
||||
},
|
||||
percent(setup),
|
||||
@@ -291,12 +300,16 @@ 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 -> 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 -> 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)
|
||||
!GameStorage.modContentReady(this, profile) ->
|
||||
showBanner(getString(R.string.home_mod_missing, GameStorage.modDirectory(this).absolutePath), warning = true)
|
||||
// Only once the disc files are there is the pack the next thing missing; before that a
|
||||
// first-time player is still reading how to get those.
|
||||
discStatus == GameStorage.DiscStatus.Ready && !GameStorage.modContentReady(this, profile) ->
|
||||
showBanner(getString(R.string.home_mod_needed_message), warning = true)
|
||||
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
|
||||
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
|
||||
showBanner(getString(R.string.home_setup_intro), warning = false)
|
||||
@@ -351,6 +364,7 @@ class LauncherActivity : Activity() {
|
||||
Action.SelectDisc -> R.string.home_select_disc
|
||||
Action.ImportGame -> R.string.home_import
|
||||
Action.BuildGame -> R.string.home_build
|
||||
Action.DownloadModPack -> R.string.home_download_mod_pack
|
||||
}
|
||||
|
||||
private fun perform(action: Action) {
|
||||
@@ -360,6 +374,25 @@ class LauncherActivity : Activity() {
|
||||
Action.SelectDisc -> selectDiscImage()
|
||||
Action.ImportGame -> importGame()
|
||||
Action.BuildGame -> buildGame()
|
||||
Action.DownloadModPack -> downloadModPack()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
val installed = RetroRewindPack.installedVersion(this)
|
||||
val message = if (installed == null) {
|
||||
getString(R.string.home_mod_pack_message)
|
||||
} else {
|
||||
getString(R.string.home_mod_pack_update_message, installed)
|
||||
}
|
||||
confirm(R.string.home_mod_pack_title, message, R.string.home_download_mod_pack) {
|
||||
GameSetupService.startModPackDownload(this)
|
||||
showPage(Page.Home)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +409,7 @@ class LauncherActivity : Activity() {
|
||||
private fun buildGame() {
|
||||
if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
|
||||
if (!GameStorage.modContentReady(this, profile)) {
|
||||
confirm(R.string.home_mod_needed_title, R.string.home_mod_needed_message, R.string.home_import) { importGame() }
|
||||
confirm(R.string.home_mod_needed_title, R.string.home_mod_needed_message, R.string.home_download_mod_pack) { downloadModPack() }
|
||||
return
|
||||
}
|
||||
val message = if (GameLibrary.status(this, profile) == GameLibrary.Status.Ready) {
|
||||
@@ -396,6 +429,7 @@ class LauncherActivity : Activity() {
|
||||
return
|
||||
}
|
||||
dataBannerText.text = text
|
||||
dataBannerText.scrollTo(0, 0)
|
||||
dataBanner.setBackgroundResource(if (warning) R.drawable.bg_banner_warning else R.drawable.bg_banner_info)
|
||||
dataBannerIcon.setImageResource(if (warning) R.drawable.ic_warning else R.drawable.ic_disc)
|
||||
dataBannerIcon.imageTintList = ColorStateList.valueOf(getColor(if (warning) R.color.warning_500 else R.color.primary_400))
|
||||
@@ -426,7 +460,10 @@ class LauncherActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirm(title: Int, message: Int, positive: Int, onConfirm: () -> Unit) {
|
||||
private fun confirm(title: Int, message: Int, positive: Int, onConfirm: () -> Unit) =
|
||||
confirm(title, getString(message), positive, onConfirm)
|
||||
|
||||
private fun confirm(title: Int, message: CharSequence, positive: Int, onConfirm: () -> Unit) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipInputStream
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
* Fetches the Retro Rewind pack the modded game reads, from Retro Rewind's own distribution server,
|
||||
* the way WheelWizard does on a PC: the server publishes the URL of a full install zip, a list of
|
||||
* update zips with their versions, and a list of files each update deletes. An installation is the
|
||||
* base zip plus every update newer than the version it holds.
|
||||
*
|
||||
* None of that content is part of this app; this only downloads what Retro Rewind publishes. Only
|
||||
* the pack's own `RetroRewind6` tree is kept, which is what `[paths] retro_rewind_root` names; the
|
||||
* Riivolution XML beside it belongs to a Wii setup and means nothing here.
|
||||
*/
|
||||
object RetroRewindPack {
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
|
||||
private const val BASE_URL = "https://update.rwfc.net/RetroRewind/"
|
||||
private const val INSTALL_URL = BASE_URL + "RetroRewindInstall.txt"
|
||||
private const val VERSION_URL = BASE_URL + "RetroRewindVersion.txt"
|
||||
private const val DELETE_URL = BASE_URL + "RetroRewindDelete.txt"
|
||||
|
||||
/** The server still lists some downloads under the host it used before; they moved, not vanished. */
|
||||
private const val OLD_HOST = "http://update.rwfc.net:8000/"
|
||||
private const val NEW_HOST = "https://update.rwfc.net/"
|
||||
|
||||
/** Everything this app keeps lives under this directory inside the published zips. */
|
||||
private const val PACK_PREFIX = GameStorage.MOD_DIRECTORY + "/"
|
||||
|
||||
private const val VERSION_FILE = "version.txt"
|
||||
private const val FREE_SPACE_MARGIN = 4L shl 30
|
||||
|
||||
/** One published update: the version it produces and the zip that gets there. */
|
||||
data class Update(val version: String, val url: String, val description: String)
|
||||
|
||||
/** One published deletion: the version that drops [path], relative to the pack's parent. */
|
||||
data class Deletion(val version: String, val path: String)
|
||||
|
||||
/**
|
||||
* Reads the version feed: `<version> <url> <path> <description>` per line, oldest first. Lines
|
||||
* that do not parse are skipped rather than failing the update, as the PC launcher does.
|
||||
*/
|
||||
fun parseUpdates(text: String): List<Update> =
|
||||
text.lineSequence().mapNotNull { line ->
|
||||
val parts = line.trim().split(' ', limit = 4)
|
||||
if (parts.size < 4) return@mapNotNull null
|
||||
val version = parts[0].trim()
|
||||
val url = parts[1].trim().replace(OLD_HOST, NEW_HOST)
|
||||
if (!isVersion(version) || url.isEmpty()) return@mapNotNull null
|
||||
Update(version, url, parts[3].trim())
|
||||
}.toList()
|
||||
|
||||
/** Reads the deletion feed: `<version> <path>` per line. */
|
||||
fun parseDeletions(text: String): List<Deletion> =
|
||||
text.lineSequence().mapNotNull { line ->
|
||||
val parts = line.trim().split(' ', limit = 2)
|
||||
if (parts.size < 2) return@mapNotNull null
|
||||
val version = parts[0].trim()
|
||||
val path = parts[1].trim()
|
||||
if (!isVersion(version) || path.isEmpty()) return@mapNotNull null
|
||||
Deletion(version, path)
|
||||
}.toList()
|
||||
|
||||
/** The updates to apply to an installation holding [installed] (null: none installed), in order. */
|
||||
fun updatesAfter(installed: String?, all: List<Update>): List<Update> =
|
||||
all.filter { installed == null || compare(it.version, installed) > 0 }.sortedWith { a, b -> compare(a.version, b.version) }
|
||||
|
||||
/**
|
||||
* The pack-relative paths the updates between [installed] and [target] delete, in order. Paths
|
||||
* outside the pack (the loose zips and the Riivolution XML the feed also lists) are not ours.
|
||||
*/
|
||||
fun deletionsBetween(installed: String?, target: String, all: List<Deletion>): List<String> =
|
||||
all
|
||||
.filter { (installed == null || compare(it.version, installed) > 0) && compare(it.version, target) <= 0 }
|
||||
.sortedWith { a, b -> compare(a.version, b.version) }
|
||||
.mapNotNull { packRelative(it.path) }
|
||||
|
||||
/** Dotted numeric comparison, which is all these versions ever are (6.12.7). */
|
||||
fun compare(left: String, right: String): Int {
|
||||
val a = left.split('.')
|
||||
val b = right.split('.')
|
||||
for (i in 0 until maxOf(a.size, b.size)) {
|
||||
val difference = (a.getOrNull(i)?.toIntOrNull() ?: 0) - (b.getOrNull(i)?.toIntOrNull() ?: 0)
|
||||
if (difference != 0) return difference
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun isVersion(value: String): Boolean =
|
||||
value.isNotEmpty() && value.split('.').let { it.size >= 2 && it.all { part -> part.toIntOrNull() != null } }
|
||||
|
||||
/**
|
||||
* A published path relative to the pack, or null when it is not inside the pack. Paths that try
|
||||
* to climb out of it are refused: nothing outside the pack directory is ever touched.
|
||||
*/
|
||||
fun packRelative(published: String): String? {
|
||||
val name = published.replace('\\', '/').trimStart('/')
|
||||
if (!name.startsWith(PACK_PREFIX)) return null
|
||||
val relative = name.removePrefix(PACK_PREFIX).trim('/')
|
||||
if (relative.isEmpty() || relative.split('/').any { it == ".." }) return null
|
||||
return relative
|
||||
}
|
||||
|
||||
/** The version the installed pack holds, or null when there is none. */
|
||||
fun installedVersion(context: Context): String? =
|
||||
File(GameStorage.modDirectory(context), VERSION_FILE)
|
||||
.takeIf { it.isFile }
|
||||
?.readText()
|
||||
?.trim()
|
||||
?.takeIf { isVersion(it) }
|
||||
|
||||
/**
|
||||
* Installs or updates the pack. Null on success, otherwise the message to show.
|
||||
*
|
||||
* A missing or version-less pack is replaced by the published base zip, staged beside it and
|
||||
* swapped in only once it looks like a pack. Updates are then applied over the installed pack
|
||||
* and the version is written last, so an interrupted update simply runs again next time.
|
||||
*/
|
||||
fun run(context: Context, progress: GameSetup.Progress, cancelled: () -> Boolean, finishing: () -> Unit): String? {
|
||||
val pack = GameStorage.modDirectory(context)
|
||||
val gameRoot = GameStorage.gameRoot(context)
|
||||
gameRoot.mkdirs()
|
||||
|
||||
val installed = installedVersion(context)
|
||||
val available = StatFs(gameRoot.absolutePath).availableBytes
|
||||
if (installed == null && available < FREE_SPACE_MARGIN) {
|
||||
return "Not enough free space: the Retro Rewind pack needs about ${GameFiles.gigabytes(FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free."
|
||||
}
|
||||
|
||||
return try {
|
||||
val updates = updatesAfter(installed, parseUpdates(fetchText(VERSION_URL, cancelled)))
|
||||
if (installed != null && updates.isEmpty()) {
|
||||
Log.i(TAG, "Retro Rewind $installed is already the published version")
|
||||
return null
|
||||
}
|
||||
|
||||
if (installed == null) {
|
||||
val base = fetchText(INSTALL_URL, cancelled).trim().replace(OLD_HOST, NEW_HOST)
|
||||
if (base.isEmpty()) return "Retro Rewind's server did not say where its download is."
|
||||
Log.i(TAG, "Installing the Retro Rewind pack from $base")
|
||||
val staging = File(gameRoot, "${GameStorage.MOD_DIRECTORY}.downloading")
|
||||
staging.deleteRecursively()
|
||||
try {
|
||||
download(base, staging, progress, cancelled)
|
||||
if (!File(staging, "Binaries/Code.pul").isFile || !File(staging, VERSION_FILE).isFile) {
|
||||
return "Retro Rewind's download did not contain the pack."
|
||||
}
|
||||
finishing()
|
||||
GameFiles.replace(pack, staging)?.let { return it }
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
val remaining = updatesAfter(installedVersion(context), updates)
|
||||
if (remaining.isNotEmpty()) {
|
||||
val deletions = parseDeletions(fetchText(DELETE_URL, cancelled))
|
||||
for (update in remaining) {
|
||||
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()
|
||||
}
|
||||
finishing()
|
||||
File(pack, VERSION_FILE).writeText(remaining.last().version)
|
||||
}
|
||||
Log.i(TAG, "Retro Rewind pack ready: ${installedVersion(context)}")
|
||||
null
|
||||
} catch (interrupted: InterruptedIOException) {
|
||||
throw interrupted
|
||||
} catch (failure: IOException) {
|
||||
"Retro Rewind could not be downloaded: ${failure.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchText(url: String, cancelled: () -> Boolean): String {
|
||||
if (cancelled()) throw InterruptedIOException("Download cancelled")
|
||||
return open(url).use { connection ->
|
||||
connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Unpacks the pack directory of the zip at [url] into [target], over whatever is there. */
|
||||
private fun download(url: String, target: File, progress: GameSetup.Progress, cancelled: () -> Boolean) {
|
||||
if (cancelled()) throw InterruptedIOException("Download cancelled")
|
||||
open(url).use { connection ->
|
||||
val total = connection.contentLengthLong
|
||||
val counted = CountingInputStream(connection.inputStream)
|
||||
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
var lastReport = 0L
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
if (entry.isDirectory) continue
|
||||
val relative = packRelative(entry.name) ?: continue
|
||||
val file = File(target, relative)
|
||||
file.parentFile?.mkdirs()
|
||||
file.outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (counted.count - lastReport >= 8L shl 20) {
|
||||
lastReport = counted.count
|
||||
if (!progress.update(counted.count, total) || cancelled()) {
|
||||
throw InterruptedIOException("Download cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
progress.update(total.coerceAtLeast(0), total)
|
||||
}
|
||||
}
|
||||
|
||||
private fun open(url: String): HttpURLConnection {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.instanceFollowRedirects = true
|
||||
// Android asks for gzip by default, which would hide the real length and re-encode the zip.
|
||||
connection.setRequestProperty("Accept-Encoding", "identity")
|
||||
val code = connection.responseCode
|
||||
if (code != HttpURLConnection.HTTP_OK) {
|
||||
connection.disconnect()
|
||||
throw IOException("Retro Rewind's server answered $code")
|
||||
}
|
||||
return connection
|
||||
}
|
||||
|
||||
private fun <T> HttpURLConnection.use(block: (HttpURLConnection) -> T): T {
|
||||
try {
|
||||
return block(this)
|
||||
} finally {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts what has actually come down the wire, which is what the progress bar reports. */
|
||||
private class CountingInputStream(input: java.io.InputStream) : java.io.FilterInputStream(input) {
|
||||
@Volatile
|
||||
var count = 0L
|
||||
private set
|
||||
|
||||
override fun read(): Int = super.read().also { if (it >= 0) count++ }
|
||||
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int =
|
||||
super.read(bytes, offset, length).also { if (it > 0) count += it }
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class SettingsPage(
|
||||
private val selectDiscImage: () -> Unit,
|
||||
private val importGame: () -> Unit,
|
||||
private val buildGame: () -> Unit,
|
||||
private val downloadModPack: () -> Unit,
|
||||
) {
|
||||
|
||||
enum class Tab(val label: Int) {
|
||||
@@ -47,6 +48,7 @@ class SettingsPage(
|
||||
Graphics(R.string.settings_tab_graphics),
|
||||
Controls(R.string.settings_tab_controls),
|
||||
Audio(R.string.settings_tab_audio),
|
||||
Other(R.string.settings_tab_other),
|
||||
About(R.string.settings_tab_about),
|
||||
}
|
||||
|
||||
@@ -91,16 +93,20 @@ class SettingsPage(
|
||||
current = store.load() ?: TomlConfig.parse("")
|
||||
rows.removeAllViews()
|
||||
dependents.clear()
|
||||
if (gameRunning()) {
|
||||
banner(R.drawable.bg_banner_warning, R.string.settings_running_note)
|
||||
} else {
|
||||
banner(R.drawable.bg_banner_info, R.string.settings_apply_note)
|
||||
// About changes nothing, so the note about when changes take effect has no place there.
|
||||
if (tab != Tab.About) {
|
||||
if (gameRunning()) {
|
||||
banner(R.drawable.bg_banner_warning, R.string.settings_running_note)
|
||||
} else {
|
||||
banner(R.drawable.bg_banner_info, R.string.settings_apply_note)
|
||||
}
|
||||
}
|
||||
when (tab) {
|
||||
Tab.Vr -> buildVr()
|
||||
Tab.Graphics -> buildGraphics()
|
||||
Tab.Controls -> buildControls()
|
||||
Tab.Audio -> buildAudio()
|
||||
Tab.Other -> buildOther()
|
||||
Tab.About -> buildAbout()
|
||||
}
|
||||
updateDependents(current)
|
||||
@@ -259,10 +265,8 @@ class SettingsPage(
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAbout() {
|
||||
section(R.string.section_about_app) {
|
||||
info(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
/** What the player's own files are and what makes them, as the PC launcher's Other tab does. */
|
||||
private fun buildOther() {
|
||||
section(R.string.section_about_storage) {
|
||||
val disc = GameStorage.discDirectory(activity).absolutePath
|
||||
val status = when (GameStorage.discStatus(activity)) {
|
||||
@@ -297,6 +301,24 @@ class SettingsPage(
|
||||
action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) {
|
||||
importGame()
|
||||
}
|
||||
// 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)
|
||||
info(
|
||||
R.string.about_mod_pack,
|
||||
installed?.let { activity.getString(R.string.about_mod_pack_version, it) }
|
||||
?: activity.getString(R.string.about_mod_pack_missing),
|
||||
stacked = true,
|
||||
)
|
||||
action(
|
||||
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,
|
||||
) {
|
||||
downloadModPack()
|
||||
}
|
||||
}
|
||||
info(R.string.about_config, store.file.absolutePath, stacked = true)
|
||||
info(R.string.about_logs, GameStorage.logsDirectory(activity).absolutePath, stacked = true)
|
||||
}
|
||||
@@ -309,6 +331,33 @@ class SettingsPage(
|
||||
}
|
||||
}
|
||||
|
||||
/** Who made what, and what this app is built on. */
|
||||
private fun buildAbout() {
|
||||
section(R.string.section_about_this_app) {
|
||||
info(R.string.about_app_name, activity.getString(R.string.about_app_summary), stacked = true)
|
||||
info(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||
// The fingerprint ties a built game to this APK, so it belongs where a player can read it out.
|
||||
GameLibrary.kitFingerprint(activity)?.let { info(R.string.about_kit, it, stacked = true) }
|
||||
}
|
||||
section(R.string.section_about_credits) {
|
||||
info(R.string.about_credit_title_vr, activity.getString(R.string.about_credit_vr), stacked = true)
|
||||
info(R.string.about_credit_title_wiicompiled, activity.getString(R.string.about_credit_wiicompiled), stacked = true)
|
||||
info(R.string.about_credit_title_retro_rewind, activity.getString(R.string.about_credit_retro_rewind), stacked = true)
|
||||
info(R.string.about_credit_title_wheel_wizard, activity.getString(R.string.about_credit_wheel_wizard), stacked = true)
|
||||
info(R.string.about_credit_title_graphics, activity.getString(R.string.about_credit_aurora), stacked = true)
|
||||
info(R.string.about_credit_title_platform, activity.getString(R.string.about_credit_openxr), stacked = true)
|
||||
info(R.string.about_credit_title_disc, activity.getString(R.string.about_credit_nod), stacked = true)
|
||||
if (BuildConfig.ON_DEVICE_BUILD) {
|
||||
info(R.string.about_credit_title_toolchain, activity.getString(R.string.about_credit_toolchain), stacked = true)
|
||||
}
|
||||
info(R.string.about_credit_title_dolphin, activity.getString(R.string.about_credit_dolphin), stacked = true)
|
||||
info(R.string.about_credit_title_logo, activity.getString(R.string.about_credit_logo), stacked = true)
|
||||
}
|
||||
section(R.string.section_about_licence) {
|
||||
info(R.string.about_licence_title, activity.getString(R.string.about_licence_text), stacked = true)
|
||||
}
|
||||
}
|
||||
|
||||
// Row construction
|
||||
|
||||
private inner class Section {
|
||||
|
||||
@@ -20,39 +20,6 @@
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/home_data_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_banner_warning"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/home_data_banner_icon"
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_warning"
|
||||
android:tint="@color/warning_500" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_data_banner_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
@@ -209,5 +176,48 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!--
|
||||
The message floats over the page instead of taking room above it: this panel is short,
|
||||
and a long message used to push the main button off the bottom. Long text scrolls inside
|
||||
the banner rather than growing it.
|
||||
-->
|
||||
<LinearLayout
|
||||
android:id="@+id/home_data_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:background="@drawable/bg_banner_warning"
|
||||
android:elevation="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/home_data_banner_icon"
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_warning"
|
||||
android:tint="@color/warning_500" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_data_banner_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:maxHeight="190dp"
|
||||
android:scrollbars="vertical"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -21,11 +21,26 @@
|
||||
<string name="home_put_on_headset">The game opens in the headset.</string>
|
||||
<string name="home_data_missing">The game is ready, but its game files (DATA) are missing. Select your own PAL (RMCP01) disc image to extract them, or import a game file from your computer that includes them.\n\nThe game files go to:\n%1$s</string>
|
||||
<string name="home_import">Import from computer</string>
|
||||
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: each game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer. Retro Rewind also needs its pack, which a Retro Rewind game file can carry.</string>
|
||||
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: each game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer. Retro Rewind also needs its pack, which this app can download from Retro Rewind\'s own server, or a Retro Rewind game file can carry.</string>
|
||||
<string name="home_game_missing">The game files are ready. Now build this game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string>
|
||||
<string name="home_game_stale">This app was updated, so the game must be built again for it: press Build on this Quest, or use Build for Quest in the WiiCompiled VR launcher again and Import from computer.</string>
|
||||
<string name="home_mod_needed_title">Retro Rewind needs its pack</string>
|
||||
<string name="home_mod_needed_message">Building Retro Rewind needs its pack (the RetroRewind6 folder) on this headset. Import a Retro Rewind game file built with the pack included, or copy the folder next to DATA yourself.</string>
|
||||
<string name="home_mod_needed_message">Retro Rewind needs its pack (the RetroRewind6 folder) on this headset. It can be downloaded here from Retro Rewind\'s own server, or arrive with a Retro Rewind game file built with the pack included.</string>
|
||||
<string name="home_download_mod_pack">Download Retro Rewind</string>
|
||||
<string name="home_mod_pack_title">Download the Retro Rewind pack?</string>
|
||||
<string name="home_mod_pack_message">Retro Rewind\'s tracks, characters and code come from Retro Rewind\'s own distribution server, the same one the computer launcher uses. That is about 2 GB over Wi‑Fi.\n\nNothing from Retro Rewind comes with this app.</string>
|
||||
<string name="home_mod_pack_update_message">Retro Rewind publishes updates the same way it publishes the pack. Only what changed since version %1$s is downloaded.</string>
|
||||
<string name="home_mod_pack_downloading">Downloading %1$d%%</string>
|
||||
<string name="home_mod_pack_running">Downloading the Retro Rewind pack from its own server. It carries on if you close this panel.</string>
|
||||
<string name="home_mod_pack_done">The Retro Rewind pack is ready.</string>
|
||||
<string name="home_mod_pack_failed">The Retro Rewind pack could not be downloaded.\n%1$s</string>
|
||||
<string name="mod_pack_downloading">Downloading Retro Rewind: %1$d%%</string>
|
||||
<string name="about_mod_pack">Retro Rewind pack</string>
|
||||
<string name="about_mod_pack_version">Version %1$s</string>
|
||||
<string name="about_mod_pack_missing">Not installed</string>
|
||||
<string name="about_mod_pack_action">Retro Rewind download</string>
|
||||
<string name="about_mod_pack_helper">Gets the pack, and later its updates, from Retro Rewind\'s own server.</string>
|
||||
<string name="about_mod_pack_update">Update</string>
|
||||
<string name="home_build">Build on this Quest</string>
|
||||
<string name="home_build_preparing">Preparing…</string>
|
||||
<string name="home_building">Building %1$d%%</string>
|
||||
@@ -68,7 +83,36 @@
|
||||
<string name="settings_tab_graphics">Graphics</string>
|
||||
<string name="settings_tab_controls">Controls</string>
|
||||
<string name="settings_tab_audio">Audio</string>
|
||||
<string name="settings_tab_other">Other</string>
|
||||
<string name="settings_tab_about">About</string>
|
||||
<string name="section_about_this_app">This app</string>
|
||||
<string name="section_about_credits">Credits</string>
|
||||
<string name="section_about_licence">Licence</string>
|
||||
<string name="about_app_name">WiiCompiled OpenXR VR</string>
|
||||
<string name="about_app_summary">Mario Kart Wii recompiled to native code, with an OpenXR renderer, running on this headset. No game code and no game data come with it: each game is built from the disc you own.</string>
|
||||
<string name="about_credit_vr">Meta Quest port and the OpenXR VR renderer by iChris4.</string>
|
||||
<string name="about_credit_wiicompiled">WiiCompiled, the static recompilation this is built on, by patchzyy and the static recompilation community.</string>
|
||||
<string name="about_credit_retro_rewind">Retro Rewind by ZPL and team, the mod distribution this app can download and play.</string>
|
||||
<string name="about_credit_wheel_wizard">Wheel Wizard by Patchzy and WantToBeeMe, the PC launcher this panel is modelled on.</string>
|
||||
<string name="about_credit_aurora">aurora by encounter (MIT) with Google\'s Dawn: the graphics layer every frame goes through.</string>
|
||||
<string name="about_credit_openxr">The OpenXR SDK by Khronos, and SDL 3 for windowing, audio and controllers.</string>
|
||||
<string name="about_credit_nod">nod by encounter, which reads your disc image, built into this app as libnod_jni.</string>
|
||||
<string name="about_credit_toolchain">On-headset builds use Termux\'s Android clang and lld, Google\'s Android NDK headers and libraries, and .NET for the translator.</string>
|
||||
<string name="about_credit_dolphin">The Dolphin emulator, the reference for how the Wii behaves.</string>
|
||||
<string name="about_credit_logo">The WiiCompiled logo by inkwreck.</string>
|
||||
<string name="about_licence_text">GNU General Public License v3. Not affiliated with, endorsed by, or associated with Nintendo. Mario Kart Wii is a trademark of Nintendo.</string>
|
||||
<string name="about_kit">Game kit</string>
|
||||
<string name="about_credit_title_vr">Meta Quest and VR</string>
|
||||
<string name="about_credit_title_wiicompiled">WiiCompiled</string>
|
||||
<string name="about_credit_title_retro_rewind">Retro Rewind</string>
|
||||
<string name="about_credit_title_wheel_wizard">Wheel Wizard</string>
|
||||
<string name="about_credit_title_graphics">Graphics</string>
|
||||
<string name="about_credit_title_platform">OpenXR and SDL</string>
|
||||
<string name="about_credit_title_disc">Disc images</string>
|
||||
<string name="about_credit_title_toolchain">On-headset builds</string>
|
||||
<string name="about_credit_title_dolphin">Dolphin</string>
|
||||
<string name="about_credit_title_logo">Logo</string>
|
||||
<string name="about_licence_title">GPL v3</string>
|
||||
<string name="settings_apply_note">Changes apply the next time the game starts. While playing, click both thumbsticks together to open the in-headset settings.</string>
|
||||
<string name="settings_running_note">The game is running, so these changes wait until it is closed and started again.</string>
|
||||
<string name="settings_write_failed">Could not save Config.toml.</string>
|
||||
@@ -162,7 +206,6 @@
|
||||
<string name="audio_muted_helper">Silences the game without changing the volumes above.</string>
|
||||
|
||||
<!-- About tab -->
|
||||
<string name="section_about_app">App</string>
|
||||
<string name="about_version">Version</string>
|
||||
<string name="section_about_storage">Files</string>
|
||||
<string name="about_game_data">Game data</string>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The headset reads Retro Rewind's published feeds exactly as the PC launcher does. */
|
||||
class RetroRewindPackTest {
|
||||
|
||||
private val versions = """
|
||||
6.12.5 https://update.rwfc.net/RetroRewind/zip/6.12.5.zip /RetroRewind6 New tracks
|
||||
6.12.6 http://update.rwfc.net:8000/RetroRewind/zip/6.12.6.zip /RetroRewind6 Fixes
|
||||
6.12.7 https://update.rwfc.net/RetroRewind/zip/6.12.7.zip /RetroRewind6 More fixes
|
||||
broken line without enough fields
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun readsTheVersionFeedAndMovesOldDownloadsToTheCurrentHost() {
|
||||
val updates = RetroRewindPack.parseUpdates(versions)
|
||||
assertEquals(listOf("6.12.5", "6.12.6", "6.12.7"), updates.map { it.version })
|
||||
assertEquals("https://update.rwfc.net/RetroRewind/zip/6.12.6.zip", updates[1].url)
|
||||
assertEquals("More fixes", updates[2].description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesOnlyTheUpdatesNewerThanTheInstalledVersion() {
|
||||
val updates = RetroRewindPack.parseUpdates(versions)
|
||||
assertEquals(listOf("6.12.6", "6.12.7"), RetroRewindPack.updatesAfter("6.12.5", updates).map { it.version })
|
||||
assertEquals(emptyList<String>(), RetroRewindPack.updatesAfter("6.12.7", updates).map { it.version })
|
||||
// Nothing installed takes every published update, oldest first.
|
||||
assertEquals(listOf("6.12.5", "6.12.6", "6.12.7"), RetroRewindPack.updatesAfter(null, updates).map { it.version })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun comparesVersionsByNumberNotByText() {
|
||||
assertTrue(RetroRewindPack.compare("6.12.7", "6.9.9") > 0)
|
||||
assertTrue(RetroRewindPack.compare("6.2", "6.2.0") == 0)
|
||||
assertTrue(RetroRewindPack.compare("6.12.7", "6.12.7") == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletesOnlyWhatTheUpdatesInRangeDropFromInsideThePack() {
|
||||
val deletions = RetroRewindPack.parseDeletions(
|
||||
"""
|
||||
6.12.5 RetroRewind6/Race/Course/old.szs
|
||||
6.12.6 riivolution/RetroRewind6.xml
|
||||
6.12.6 RetroRewind6/Assets/gone.brres
|
||||
6.12.7 RetroRewindOld.zip
|
||||
6.12.8 RetroRewind6/Assets/future.brres
|
||||
""".trimIndent()
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Race/Course/old.szs", "Assets/gone.brres"),
|
||||
RetroRewindPack.deletionsBetween(null, "6.12.7", deletions),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Assets/gone.brres"),
|
||||
RetroRewindPack.deletionsBetween("6.12.5", "6.12.7", deletions),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsOnlyPackPathsAndRefusesOnesThatClimbOut() {
|
||||
assertEquals("Binaries/Code.pul", RetroRewindPack.packRelative("RetroRewind6/Binaries/Code.pul"))
|
||||
assertEquals("Binaries/Code.pul", RetroRewindPack.packRelative("/RetroRewind6\\Binaries\\Code.pul"))
|
||||
assertNull(RetroRewindPack.packRelative("riivolution/RetroRewind6.xml"))
|
||||
assertNull(RetroRewindPack.packRelative("RetroRewind6/../escape.txt"))
|
||||
assertNull(RetroRewindPack.packRelative("RetroRewind6/"))
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -259,9 +259,21 @@ launcher's toggle switches between them; the selected one is remembered in `file
|
||||
(a plain file, because the launcher and the `:game` process do not share preferences).
|
||||
|
||||
Retro Rewind also needs its 2 GB pack on the headset. The app writes `[paths] retro_rewind_root`
|
||||
into `Config.toml`, Home says so when the pack is missing, and a `.wcgame` can carry it (below).
|
||||
Building the mod on the headset additionally needs the mod's `Code.pul`, which arrives with that
|
||||
pack, so **Build on this Quest** offers Retro Rewind only once the pack is installed.
|
||||
into `Config.toml`, and the pack arrives either way a PC player gets it:
|
||||
|
||||
- **Download Retro Rewind** (`RetroRewindPack`, a `GameSetup` task) fetches it from Retro Rewind's
|
||||
own distribution server, exactly as WheelWizard does on a PC. `RetroRewindInstall.txt` names the
|
||||
full install zip, `RetroRewindVersion.txt` lists `<version> <url> <path> <description>` per
|
||||
published update and `RetroRewindDelete.txt` lists `<version> <path>` deletions; an installation
|
||||
is the base zip plus every update newer than the `version.txt` it holds. Only entries under
|
||||
`RetroRewind6/` are kept (the Riivolution XML beside them belongs to a Wii setup), a base install
|
||||
is staged and swapped like every other task, and the version is written after the last update, so
|
||||
an interrupted update simply runs again. Nothing of the pack ships in the APK.
|
||||
- Or a `.wcgame` carries it (below), for a headset with no Wi‑Fi to spare.
|
||||
|
||||
Home's main button becomes **Download Retro Rewind** whenever that game is selected and its pack is
|
||||
missing, and Settings → About shows the installed version with an Update button. Building the mod on
|
||||
the headset needs the mod's `Code.pul`, which is part of the pack, so the same rule covers it.
|
||||
|
||||
### Game packages (.wcgame) and Import from computer
|
||||
|
||||
|
||||
@@ -397,9 +397,11 @@ inline void EnsureConfigFile() {
|
||||
"texture_replacements = false\n"
|
||||
"texture_dumps = false\n\n"
|
||||
"[vr]\n"
|
||||
"# OpenXR is opt-in. If required is false, startup failures fall back\n"
|
||||
"# to the ordinary desktop renderer. These values are read at launch.\n"
|
||||
"enabled = false\n"
|
||||
"# This is the OpenXR VR build, so the headset path is on. If required\n"
|
||||
"# is false, startup failures (no runtime, no headset, an unsupported\n"
|
||||
"# GPU) fall back to the ordinary desktop renderer, so turning enabled\n"
|
||||
"# off is a preference, not a fix. These values are read at launch.\n"
|
||||
"enabled = true\n"
|
||||
"required = false\n"
|
||||
"# What the desktop window shows while the headset is running:\n"
|
||||
"# \"normal\" keeps the ordinary desktop view, \"both\", \"left\" and\n"
|
||||
|
||||
@@ -131,7 +131,9 @@ int g_displayMode = [] {
|
||||
bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true);
|
||||
bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true);
|
||||
bool g_showFps = RuntimeConfigFile::ShowFps(true);
|
||||
bool g_vrEnabled = RuntimeConfigFile::VrEnabled(false);
|
||||
// The same default the VR path itself takes (kVrEnabledDefault), so the F10 switch
|
||||
// shows what an unconfigured installation actually starts in.
|
||||
bool g_vrEnabled = RuntimeConfigFile::VrEnabled(true);
|
||||
bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true);
|
||||
bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true);
|
||||
bool g_vrHudVirtualScreen = RuntimeConfigFile::VrHudVirtualScreen(true);
|
||||
|
||||
@@ -54,13 +54,10 @@ namespace {
|
||||
|
||||
inline constexpr float kDegreesToRadians = 0.01745329252f;
|
||||
|
||||
// A standalone headset has no desktop to fall back to, so VR is on unless the
|
||||
// user's configuration turns it off. Desktop builds keep the opt-in default.
|
||||
#if defined(__ANDROID__)
|
||||
// This is the VR build, so the headset path is what an unconfigured installation
|
||||
// starts in, on the headset and on the desktop alike. Nothing is lost by it:
|
||||
// with vr.required false, a missing runtime or headset falls back to desktop.
|
||||
inline constexpr bool kVrEnabledDefault = true;
|
||||
#else
|
||||
inline constexpr bool kVrEnabledDefault = false;
|
||||
#endif
|
||||
|
||||
void ConfigurePolicy(bool enabled) noexcept {
|
||||
MkwVRPolicyReset();
|
||||
|
||||
Reference in new issue
Block a user