Updated Quest Launcher & Added Retro Rewind Automatic Download

This commit is contained in:
iChris4 committed 2026-09-18 01:27:17 +02:00
1 parent ee5df403ac
commit d2fa3789b0
14 files changed
+578 -72

No files matched your search

@@ -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 {
+43 -33
View File
@@ -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>
+46 -3
View File
@@ -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/"))
}
}