Added Android Launcher and Disk Extration

This commit is contained in:
iChris4 committed 2026-09-17 18:32:04 +02:00
1 parent 1346e53cd6
commit 2f85207c39
74 files changed
+6153 -70

No files matched your search

@@ -0,0 +1,65 @@
package org.wiicompiled.quest
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
import org.junit.Test
import org.junit.runner.RunWith
/**
* What an app targeting API 29+ may run from its own private storage, which is
* where an on-device build puts the compiler and the compiled game.
*
* Run with `adb shell am instrument`, never `gradlew connectedAndroidTest`: that
* task uninstalls the app afterwards, and with it the player's extracted DATA.
*/
@RunWith(AndroidJUnit4::class)
class PrivateCodeExecutionTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val probe = File(context.filesDir, "probe").apply { mkdirs() }
@Test
fun loadsALibraryFromPrivateStorage() {
val library = File(probe, "libnod_jni_copy.so")
ZipFile(context.applicationInfo.sourceDir).use { apk ->
apk.getInputStream(apk.getEntry("lib/arm64-v8a/libnod_jni.so")).use { input ->
library.outputStream().use { input.copyTo(it) }
}
}
report("System.load from filesDir") { System.load(library.absolutePath); "loaded" }
}
@Test
fun runsABinaryFromPrivateStorage() {
val binary = File(probe, "toybox")
File("/system/bin/toybox").inputStream().use { input -> binary.outputStream().use { input.copyTo(it) } }
binary.setExecutable(true)
report("exec from filesDir") { run(binary.absolutePath, "echo", "direct") }
report("exec through /system/bin/linker64") { run("/system/bin/linker64", binary.absolutePath, "echo", "linker") }
}
private fun run(vararg command: String): String {
val process = ProcessBuilder(*command).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText().trim()
process.waitFor(30, TimeUnit.SECONDS)
return "exit ${process.exitValue()}: $output"
}
private fun report(what: String, probe: () -> String) {
val outcome = try {
probe()
} catch (t: Throwable) {
"FAILED ${t.javaClass.simpleName}: ${t.message}"
}
Log.i(TAG, "$what -> $outcome")
InstrumentationRegistry.getInstrumentation().sendStatus(0, android.os.Bundle().apply { putString("stream", "$what -> $outcome\n") })
}
private companion object {
const val TAG = "WiiCompiledProbe"
}
}
+35 -1
View File
@@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- The launcher's disc image extraction runs as a foreground service that keeps the CPU awake. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- The Khronos loader brokers the system OpenXR runtime through this permission. -->
<uses-permission android:name="org.khronos.openxr.permission.OPENXR_SYSTEM" />
@@ -38,6 +42,35 @@
<!-- Let the runtime move CPU budget to the GPU; Aurora's replay is GPU-bound. -->
<meta-data android:name="com.oculus.trade_cpu_for_gpu_amount" android:value="-1" />
<!-- The app opens on this 2D panel: Play starts QuestActivity, Settings edits Config.toml. -->
<activity
android:name=".launcher.LauncherActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|density"
android:exported="true"
android:resizeableActivity="true"
android:theme="@style/Theme.WiiCompiled.Launcher">
<!-- Horizon OS panel size. -->
<layout
android:defaultWidth="1024dp"
android:defaultHeight="640dp"
android:minWidth="760dp"
android:minHeight="520dp" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Extracts the player's disc image and imports games built on a PC (launcher process). -->
<service
android:name=".launcher.GameSetupService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- The immersive game, as DolphinXR's EmulationActivity: VR category, no
launcher entry, still exported so adb can start it directly. Its own
process, which QuestActivity ends when the game closes: SDL and the
runtime cannot start twice in one process. -->
<activity
android:name=".QuestActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode|density"
@@ -45,6 +78,7 @@
android:exported="true"
android:launchMode="singleTask"
android:preferMinimalPostProcessing="true"
android:process=":game"
android:resizeableActivity="false"
android:screenOrientation="landscape">
<property
@@ -53,7 +87,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
+3 -2
View File
@@ -49,8 +49,9 @@ endif()
add_subdirectory("${MKW_REPO_ROOT}/runtime" wiicompiled-runtime)
# App-owned JNI glue lives in each product library SDLActivity loads.
foreach(product IN ITEMS WiiCompiled RetroRewind)
# App-owned JNI glue lives in each product library SDLActivity loads, and in the game kit
# probe (runtime/cmake/PublicProducts.cmake) whose objects the player's game is linked with.
foreach(product IN ITEMS WiiCompiled RetroRewind mkw_quest_kit_probe)
if(TARGET ${product})
target_sources(${product} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/quest_surface_jni.cpp")
target_include_directories(${product} PRIVATE "${MKW_REPO_ROOT}/aurora-main/include")
@@ -0,0 +1,54 @@
package org.wiicompiled.quest
import android.content.Context
import java.io.File
import org.json.JSONObject
/**
* The player's compiled game: libmain.so built from their own disc against the game kit this
* APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with.
*
* It lives in internal private storage, the one place Android lets an app load native code it
* did not install (execution from shared or external storage is blocked). A library built for
* another kit, meaning another app version's runtime, is Stale and must be rebuilt: it would link
* against runtime objects this APK no longer has.
*/
object GameLibrary {
enum class Status { Missing, Stale, Ready }
const val LIBRARY_NAME = "libmain.so"
const val MANIFEST_NAME = "game.json"
@Volatile
private var cachedKitFingerprint: String? = null
fun directory(context: Context): File = File(context.filesDir, "game/${BuildConfig.PROFILE}")
fun library(context: Context): File = File(directory(context), LIBRARY_NAME)
fun manifest(context: Context): JSONObject? = File(directory(context), MANIFEST_NAME)
.takeIf { it.isFile }
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
/** The fingerprint of the kit in this APK's assets, which every game must be built against. */
fun kitFingerprint(context: Context): String? {
cachedKitFingerprint?.let { return it }
val fingerprint = runCatching {
context.assets.open("game_kit/kit.json").use { JSONObject(it.reader().readText()).getString("fingerprint") }
}.getOrNull()
cachedKitFingerprint = fingerprint
return fingerprint
}
fun status(context: Context): Status {
if (!BuildConfig.EXTERNAL_GAME) {
return Status.Ready
}
val manifest = manifest(context)
if (manifest == null || !library(context).isFile) {
return Status.Missing
}
return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale
}
}
@@ -0,0 +1,82 @@
package org.wiicompiled.quest
import android.content.Context
import java.io.File
/**
* Where the game's user files live on the headset. The launcher and the game
* activity run in different processes and both resolve every path here, so
* they cannot disagree about which Config.toml or DATA directory is meant.
*
* Everything sits under Android/data/<package>/files/WiiCompiledOpenXRVR, the
* one directory a player can reach over adb: the extracted disc (DATA),
* Config.toml, NAND saves and per-run logs.
*/
object GameStorage {
// Must match kApplicationDirectoryName in runtime/include/runtime_config.h.
const val APP_DIRECTORY = "WiiCompiledOpenXRVR"
const val DISC_DIRECTORY = "DATA"
enum class DiscStatus { Missing, Incomplete, Ready }
fun dataRoot(context: Context): File = context.getExternalFilesDir(null) ?: context.filesDir
fun gameRoot(context: Context): File = File(dataRoot(context), APP_DIRECTORY)
fun discDirectory(context: Context): File = File(gameRoot(context), DISC_DIRECTORY)
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
/** Game packages dropped here (adb push, Build-QuestGame.ps1 -Install) are imported when the launcher opens. */
fun importDirectory(context: Context): File = File(gameRoot(context), "Import")
/**
* Whether DATA holds what the runtime's DVD layer accepts (IsDvdDataRoot in
* runtime/src/hle/storage/dvd.cpp): the whole extracted partition, not just
* its files/ folder. Incomplete also covers a tree adb left unreadable to
* the app, which looks the same from here.
*/
fun discStatus(context: Context): DiscStatus {
val disc = discDirectory(context)
if (!disc.isDirectory) {
return DiscStatus.Missing
}
val usable = File(disc, "files").isDirectory && File(disc, "sys/fst.bin").isFile
return if (usable) DiscStatus.Ready else DiscStatus.Incomplete
}
/**
* Creates the game directory and a first Config.toml with VR on and the
* disc path filled in. An existing file is left alone: the launcher, the
* in-headset settings panel and the player all edit it in place.
*/
fun prepare(context: Context): File {
val gameRoot = gameRoot(context)
gameRoot.mkdirs()
// Created by the app so it owns it and can remove imported packages; adb only adds files.
importDirectory(context).mkdirs()
val config = configFile(context)
if (!config.isFile) {
val disc = discDirectory(context).absolutePath
config.writeText(
"""
# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.
[paths]
dvd_root = "$disc"
[video]
widescreen = true
resolution_multiplier = 1.0
[vr]
enabled = true
render_scale = 1.0
""".trimIndent() + "\n",
)
}
return gameRoot
}
}
@@ -3,6 +3,9 @@ package org.wiicompiled.quest
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.system.Os
import android.util.Log
import java.io.File
@@ -11,7 +14,9 @@ import org.libsdl.app.SDLActivity
import org.libsdl.app.SDLSurface
/**
* The one activity of the standalone Quest build.
* The game: the immersive activity that runs the native runtime and the player's
* own compiled game. The app opens on [org.wiicompiled.quest.launcher.LauncherActivity],
* whose Play button starts this one.
*
* SDLActivity owns the surface, the Java-side event pump and the JNI plumbing
* the OpenXR loader needs (the runtime fetches the JavaVM and this activity
@@ -19,44 +24,78 @@ import org.libsdl.app.SDLSurface
* adds is everything the native runtime cannot discover on its own:
*
* - the data directory the player fills with the extracted disc
* (Android/data/<package>/files/WiiCompiled/DATA) and where Config.toml,
* saves and logs live;
* - the bundled read-only runtime resources, unpacked from the APK once;
* - a Config.toml with VR enabled and the disc path filled in.
* (Android/data/<package>/files/WiiCompiledOpenXRVR/DATA) and where
* Config.toml, saves and logs live ([GameStorage]);
* - the bundled read-only runtime resources, unpacked from the APK once.
*
* The paths are handed over through the environment before SDLActivity's own
* onCreate loads the native libraries, because the runtime resolves them from
* static initialisers where SDL's JNI helpers are not yet usable.
*
* This activity lives in its own `:game` process, and that process ends with
* it. Neither SDL nor the runtime can start a second time in one process:
* SDLActivity calls System.exit on a re-created activity once SDL_main has run,
* and guest memory, fibers and the OpenXR device are process-wide. A fresh
* process per session also keeps the launcher alive when the game exits.
*/
class QuestActivity : SDLActivity() {
override fun getLibraries(): Array<String> = arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
override fun getLibraries(): Array<String> =
if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
/**
* The game is not part of the APK: it is the libmain.so the player built from their own disc
* ([GameLibrary]), loaded from private storage. Its libSDL3.so, libpng16.so and
* libc++_shared.so dependencies resolve against the ones this APK installed.
*/
override fun loadLibraries() {
super.loadLibraries()
if (BuildConfig.EXTERNAL_GAME) {
// SDLActivity reports a failed load in its own error dialog and never starts the game.
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
}
System.load(GameLibrary.library(this).absolutePath)
}
}
override fun getMainSharedObject(): String =
if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject()
override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context)
override fun onCreate(savedInstanceState: Bundle?) {
val dataRoot = (getExternalFilesDir(null) ?: filesDir)
val gameRoot = File(dataRoot, APP_DIRECTORY)
gameRoot.mkdirs()
GameStorage.prepare(this)
val resources = unpackRuntimeResources()
Os.setenv("MKW_ANDROID_DATA_DIR", dataRoot.absolutePath, true)
Os.setenv("MKW_ANDROID_DATA_DIR", GameStorage.dataRoot(this).absolutePath, true)
Os.setenv("MKW_ANDROID_RESOURCES_DIR", resources.absolutePath, true)
ensureConfig(gameRoot)
super.onCreate(savedInstanceState)
if (!File(gameRoot, DISC_DIRECTORY).isDirectory && !mBrokenLibraries) {
Log.w(TAG, "No extracted disc found under ${gameRoot.absolutePath}")
// The launcher does not offer Play without DATA; this covers a direct
// start, such as adb am start.
if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing && !mBrokenLibraries) {
val disc = GameStorage.discDirectory(this)
Log.w(TAG, "No extracted disc found under ${disc.parentFile?.absolutePath}")
AlertDialog.Builder(this)
.setTitle(getString(R.string.missing_game_data_title))
.setMessage(getString(R.string.missing_game_data_message, File(gameRoot, DISC_DIRECTORY).absolutePath))
.setMessage(getString(R.string.missing_game_data_message, disc.absolutePath))
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
}
}
override fun onDestroy() {
// SDLActivity asks SDL_main to quit and waits up to a second for it.
super.onDestroy()
// Ended from the main looper so the activity manager has already
// recorded this destruction and returns to the launcher.
Log.i(TAG, "Game activity destroyed; ending the game process")
Handler(Looper.getMainLooper()).post { Process.killProcess(Process.myPid()) }
}
/**
* Copies runtime_resources/ from the APK assets into private storage the
* first time this build runs. A stamp file keyed on the version code keeps
@@ -92,37 +131,7 @@ class QuestActivity : SDLActivity() {
}
}
/**
* Writes a first Config.toml pointing at the disc directory with VR on.
* An existing file is left alone: the settings overlay edits it in place.
*/
private fun ensureConfig(gameRoot: File) {
val config = File(gameRoot, "Config.toml")
if (config.isFile) {
return
}
val disc = File(gameRoot, DISC_DIRECTORY).absolutePath
config.writeText(
"""
# WiiCompiled Quest configuration. Edit with adb pull/push or the in-game overlay.
[paths]
dvd_root = "$disc"
[video]
widescreen = true
resolution_multiplier = 1.0
[vr]
enabled = true
render_scale = 1.0
""".trimIndent() + "\n",
)
}
companion object {
private const val TAG = "WiiCompiledQuest"
// Must match kApplicationDirectoryName in runtime/include/runtime_config.h.
private const val APP_DIRECTORY = "WiiCompiledOpenXRVR"
private const val DISC_DIRECTORY = "DATA"
private companion object {
const val TAG = "WiiCompiledQuest"
}
}
@@ -0,0 +1,51 @@
package org.wiicompiled.quest.launcher
import android.util.Log
import java.io.File
import java.io.IOException
/**
* Config.toml on disk. Every edit re-reads the file first, so a value the
* in-headset panel wrote meanwhile is never overwritten with a stale copy.
*/
class ConfigStore(val file: File) {
/** The current file, empty when it does not exist yet, or null when it cannot be read. */
fun load(): TomlConfig? {
if (!file.isFile) {
return TomlConfig.parse("")
}
return try {
TomlConfig.parse(file.readText())
} catch (e: IOException) {
Log.w(TAG, "Cannot read ${file.absolutePath}", e)
null
}
}
/** Applies [edit] to a fresh read and writes the result back. */
fun update(edit: (TomlConfig) -> Unit): Boolean {
// A file that exists but cannot be read must not be replaced by one
// holding only this edit.
val config = load() ?: return false
edit(config)
val text = config.text()
return try {
file.parentFile?.mkdirs()
val temporary = File(file.parentFile, "${file.name}.tmp")
temporary.writeText(text)
if (!temporary.renameTo(file)) {
temporary.delete()
file.writeText(text)
}
true
} catch (e: IOException) {
Log.w(TAG, "Cannot write ${file.absolutePath}", e)
false
}
}
private companion object {
const val TAG = "WiiCompiledLauncher"
}
}
@@ -0,0 +1,74 @@
package org.wiicompiled.quest.launcher
import java.security.MessageDigest
import java.util.Locale
/**
* The PC installer's acceptance rules for a player's disc image, in one place:
* Launcher/WiiCompiled.Setup.Windows/InputValidation.cs (formats, game ID) and
* InstallerEngine.ValidateExtractedGame (the clean PAL main.dol and StaticR.rel).
* The pins come from projects/mkwii/recomp.yml through BuildConfig.
*/
class DiscChecks(
private val gameId: String,
private val dolSha256: String,
private val relSha256: String,
) {
data class Header(val gameId: String, val title: String, val extractedBytes: Long)
/** Null when [displayName] is a format nod reads, otherwise the message to show. */
fun extensionError(displayName: String?): String? {
val extension = displayName?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty()
return if (extension in SUPPORTED_EXTENSIONS) null else FORMAT_MESSAGE
}
fun compatibilityError(header: Header): String? {
if (header.gameId.equals(gameId, ignoreCase = true)) {
return null
}
return "This build supports Mario Kart Wii PAL ($gameId). The selected image is " +
"${header.gameId} (${header.title}, ${region(header.gameId)})."
}
fun revisionError(dol: ByteArray, rel: ByteArray): String? {
if (sha256(dol).equals(dolSha256, ignoreCase = true) && sha256(rel).equals(relSha256, ignoreCase = true)) {
return null
}
return "The disc is $gameId but does not match the supported clean PAL revision. " +
"Patched or otherwise modified game code cannot be installed safely."
}
companion object {
const val DOL_PATH = "sys/main.dol"
const val REL_PATH = "files/rel/StaticR.rel"
const val FST_PATH = "sys/fst.bin"
private val SUPPORTED_EXTENSIONS = setOf("iso", "gcm", "gcz", "ciso", "wbfs", "wia", "rvz")
private const val FORMAT_MESSAGE =
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format."
/** NodDisc.header's "<game id>\n<title>\n<bytes>". */
fun parseHeader(text: String): Header {
val lines = text.split('\n')
return Header(
gameId = lines.getOrElse(0) { "" },
title = lines.getOrElse(1) { "" },
extractedBytes = lines.getOrNull(2)?.toLongOrNull() ?: 0L,
)
}
fun region(gameId: String): String = when (gameId.getOrNull(3)) {
'P' -> "PAL"
'E' -> "NTSC-U"
'J' -> "NTSC-J"
'K' -> "Korea"
'W' -> "Taiwan"
null -> "Unknown"
else -> gameId[3].toString()
}
fun sha256(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,65 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.os.StatFs
import android.util.Log
import java.io.File
import java.io.IOException
import org.wiicompiled.quest.GameStorage
/**
* The player's disc image becomes DATA, the way the PC installer does it
* (Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs): read the header and require RMCP01,
* check main.dol and StaticR.rel against the clean PAL pins, extract the data partition with nod,
* and check the extracted files again before they replace DATA.
*
* The image is checked before anything is written, so a wrong disc fails in seconds rather than
* after the extraction.
*/
object DiscExtraction {
private const val TAG = "WiiCompiledLauncher"
private const val STAGING_DIRECTORY = "DATA.extracting"
// Headroom beyond the files themselves: FUSE and directory overhead.
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
/** Null on success, otherwise the message to show. */
fun run(context: Context, fd: Int, name: String?, progress: GameSetup.Progress, finishing: () -> Unit): String? {
val checks = GameFiles.checks
checks.extensionError(name)?.let { return it }
val header = try {
DiscChecks.parseHeader(NodDisc.header(fd))
} catch (e: IOException) {
return "This disc image could not be read. ${e.message.orEmpty()}".trim()
}
checks.compatibilityError(header)?.let { return it }
checks.revisionError(NodDisc.readFile(fd, DiscChecks.DOL_PATH), NodDisc.readFile(fd, DiscChecks.REL_PATH))
?.let { return it }
val gameRoot = GameStorage.gameRoot(context)
gameRoot.mkdirs()
val staging = File(gameRoot, STAGING_DIRECTORY)
staging.deleteRecursively()
try {
val needed = header.extractedBytes + FREE_SPACE_MARGIN
val available = StatFs(gameRoot.absolutePath).availableBytes
if (available < needed) {
return "Not enough free space: the game files need ${GameFiles.gigabytes(needed)}, and ${GameFiles.gigabytes(available)} is free."
}
staging.mkdirs()
progress.update(0, header.extractedBytes)
NodDisc.extract(fd, staging.absolutePath) { done, total -> progress.update(done, total) }
finishing()
GameFiles.validateData(staging)?.let { return it }
GameFiles.replace(GameStorage.discDirectory(context), staging)?.let { return it }
Log.i(TAG, "Game data extracted into ${GameStorage.discDirectory(context).absolutePath}")
return null
} finally {
// Gone already after a successful run, which moved it to DATA.
staging.deleteRecursively()
}
}
}
@@ -0,0 +1,50 @@
package org.wiicompiled.quest.launcher
import android.util.Log
import java.io.File
import org.wiicompiled.quest.BuildConfig
/** Checks and swaps shared by every [GameSetup] task. */
object GameFiles {
private const val TAG = "WiiCompiledLauncher"
val checks = DiscChecks(BuildConfig.DISC_GAME_ID, BuildConfig.DISC_DOL_SHA256, BuildConfig.DISC_REL_SHA256)
/**
* InstallerEngine.ValidateExtractedGame (the PC installer): the files the runtime needs, with
* the pinned hashes. Null when [root] is a usable DATA folder.
*/
fun validateData(root: File): String? {
val dol = File(root, DiscChecks.DOL_PATH)
val rel = File(root, DiscChecks.REL_PATH)
if (!dol.isFile || !rel.isFile || !File(root, DiscChecks.FST_PATH).isFile) {
return "The game files are missing required Mario Kart Wii files."
}
return checks.revisionError(dol.readBytes(), rel.readBytes())
}
/**
* Moves [staging] to [destination], keeping the old destination until the new one is in
* place. Null on success, otherwise the message to show.
*/
fun replace(destination: File, staging: File): String? {
val replaced = File(destination.parentFile, "${destination.name}.replaced")
replaced.deleteRecursively()
if (destination.exists() && !destination.renameTo(replaced)) {
return "The existing ${destination.name} folder could not be replaced. Remove it, for example with " +
"adb shell rm -r ${destination.absolutePath}, and try again."
}
if (!staging.renameTo(destination)) {
replaced.renameTo(destination)
return "The new files could not be moved into ${destination.absolutePath}."
}
// The new copy is in place; an old one that will not delete only costs space.
if (replaced.exists() && !replaced.deleteRecursively()) {
Log.w(TAG, "Could not remove ${replaced.absolutePath}")
}
return null
}
fun gigabytes(bytes: Long) = "%.1f GB".format(bytes / 1_000_000_000.0)
}
@@ -0,0 +1,163 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.os.ParcelFileDescriptor
import android.os.StatFs
import android.util.Log
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.FilterInputStream
import java.io.InputStream
import java.io.InterruptedIOException
import java.security.MessageDigest
import java.util.zip.ZipInputStream
import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameStorage
/**
* A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest")
* becomes this headset's game: libmain.so and game.json, and DATA too when the package carries
* the extracted disc.
*
* The package is only accepted for the disc and app it was built for: game.json must name the
* pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
* in the package. Both parts are staged and checked before either replaces what is installed.
*/
object GamePackageImport {
private const val TAG = "WiiCompiledLauncher"
private const val SCHEMA = 1
private const val DATA_PREFIX = "DATA/"
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
/** Null on success, otherwise the message to show. */
fun run(context: Context, descriptor: ParcelFileDescriptor, progress: GameSetup.Progress, finishing: () -> Unit): String? {
val total = descriptor.statSize
val gameRoot = GameStorage.gameRoot(context)
val available = StatFs(gameRoot.absolutePath).availableBytes
if (total > 0 && available < total + FREE_SPACE_MARGIN) {
return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free."
}
val libraryDir = GameLibrary.directory(context)
val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing")
val dataStaging = File(gameRoot, "DATA.importing")
libraryStaging.deleteRecursively()
dataStaging.deleteRecursively()
try {
libraryStaging.mkdirs()
var manifestText: String? = null
var librarySha256: String? = null
var hasData = false
val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor))
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
val buffer = ByteArray(1 shl 20)
var lastReport = 0L
while (true) {
val entry = zip.nextEntry ?: break
val name = entry.name
if (entry.isDirectory) continue
if (name.startsWith("/") || name.split('/').any { it == ".." }) {
return "The game file contains an invalid path: $name"
}
when {
name == GameLibrary.MANIFEST_NAME -> manifestText = zip.readBytes().toString(Charsets.UTF_8)
name == GameLibrary.LIBRARY_NAME -> {
val digest = MessageDigest.getInstance("SHA-256")
File(libraryStaging, GameLibrary.LIBRARY_NAME).outputStream().use { out ->
while (true) {
val read = zip.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
out.write(buffer, 0, read)
}
}
librarySha256 = digest.digest().joinToString("") { "%02x".format(it) }
}
name.startsWith(DATA_PREFIX) -> {
hasData = true
val target = File(dataStaging, name.removePrefix(DATA_PREFIX))
target.parentFile?.mkdirs()
target.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)) throw InterruptedIOException("Import cancelled")
}
}
}
}
}
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
}
}
finishing()
val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() }
?: return "This is not a WiiCompiled game file (game.json is missing)."
manifestError(context, manifestJson, librarySha256)?.let { return it }
if (hasData) {
GameFiles.validateData(dataStaging)?.let { return it }
} else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first."
}
File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!)
if (hasData) {
GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it }
}
libraryDir.parentFile?.mkdirs()
GameFiles.replace(libraryDir, libraryStaging)?.let { return it }
Log.i(TAG, "Imported the game built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
return null
} finally {
libraryStaging.deleteRecursively()
dataStaging.deleteRecursively()
}
}
private fun manifestError(context: Context, manifest: JSONObject, librarySha256: String?): String? {
if (manifest.optInt("schema") != SCHEMA) {
return "This game file comes from a newer or older launcher (format ${manifest.optInt("schema")}). Build it again with the current PC launcher."
}
if (manifest.optString("profile") != BuildConfig.PROFILE) {
return "This game file is for ${manifest.optString("profile")}, but this app is ${BuildConfig.PROFILE}."
}
if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) ||
!manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) ||
!manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true)
) {
return "This game file was not built from the supported clean PAL Mario Kart Wii (${BuildConfig.DISC_GAME_ID})."
}
if (manifest.optString("kitFingerprint") != GameLibrary.kitFingerprint(context)) {
return "This game file was built for a different version of this app. Build it again against the version installed on the headset."
}
val expected = manifest.optString("librarySha256")
if (librarySha256 == null) {
return "This game file has no game library (libmain.so)."
}
if (!expected.equals(librarySha256, ignoreCase = true)) {
return "The game library in this file is damaged (its checksum does not match). Copy the file again."
}
return null
}
private class CountingInputStream(input: InputStream) : FilterInputStream(input) {
@Volatile
var count = 0L
private set
override fun read(): Int = super.read().also { if (it >= 0) count++ }
override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, len).also { if (it > 0) count += it }
override fun skip(n: Long): Long = super.skip(n).also { count += it }
}
}
@@ -0,0 +1,123 @@
package org.wiicompiled.quest.launcher
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.io.File
import java.io.InterruptedIOException
/**
* The long-running steps that make the game playable from what the player owns, one at a time,
* process-wide. [GameSetupService] runs them in the foreground and the launcher watches [state].
*
* - [Task.ExtractDisc]: the player's disc image becomes DATA ([DiscExtraction]).
* - [Task.ImportPackage]: a .wcgame built on a PC becomes the game library, and DATA too when
* the package carries it ([GamePackageImport]).
*
* 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 }
sealed interface State {
data object Idle : State
data class Checking(val task: Task) : State
data class Working(val task: Task, val done: Long, val total: Long) : State
data class Finishing(val task: Task) : State
data class Done(val task: Task) : State
data class Failed(val task: Task, val message: String) : State
data class Cancelled(val task: Task) : State
}
/** What a task step reports; returning false from [update] asks it to stop. */
fun interface Progress {
fun update(done: Long, total: Long): Boolean
}
private const val TAG = "WiiCompiledLauncher"
private val main = Handler(Looper.getMainLooper())
private val listeners = mutableSetOf<(State) -> Unit>()
@Volatile
var state: State = State.Idle
private set
@Volatile
private var cancelRequested = false
val isRunning: Boolean
get() = state is State.Checking || state is State.Working || state is State.Finishing
/** Listeners are called on the main thread, with the current state first. */
fun addListener(listener: (State) -> Unit) {
listeners += listener
listener(state)
}
fun removeListener(listener: (State) -> Unit) {
listeners -= listener
}
fun cancel() {
cancelRequested = true
}
/** Claims the single slot for [task]; false when another task is running. */
fun begin(task: Task): Boolean {
synchronized(this) {
if (isRunning) return false
cancelRequested = false
publish(State.Checking(task))
return true
}
}
/** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. */
fun run(context: Context, task: Task, uri: Uri, deleteSource: Boolean) {
val progress = Progress { done, total ->
publish(State.Working(task, done, total))
!cancelRequested
}
val finishing = { publish(State.Finishing(task)) }
try {
val failure = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
when (task) {
Task.ExtractDisc -> DiscExtraction.run(context, descriptor.fd, displayName(context, uri), progress, finishing)
Task.ImportPackage -> GamePackageImport.run(context, descriptor, progress, finishing)
}
} ?: "The selected file could not be opened."
if (failure != null) {
publish(State.Failed(task, failure))
return
}
if (deleteSource && uri.scheme == "file") {
uri.path?.let { File(it).delete() }
}
publish(State.Done(task))
} catch (e: InterruptedIOException) {
publish(State.Cancelled(task))
} catch (e: Exception) {
Log.e(TAG, "$task failed", e)
publish(State.Failed(task, e.message ?: e.toString()))
}
}
fun displayName(context: Context, uri: Uri): String? =
runCatching {
context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull() ?: uri.lastPathSegment
private fun publish(next: State) {
state = next
main.post {
for (listener in listeners.toList()) listener(next)
}
}
}
@@ -0,0 +1,136 @@
package org.wiicompiled.quest.launcher
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import kotlin.concurrent.thread
import org.wiicompiled.quest.R
/**
* Runs a [GameSetup] task as a foreground service. Extracting a disc or importing a game takes
* minutes, and a panel app's process is otherwise fair game once the panel is closed; taking the
* headset off also sleeps the CPU, hence the partial wake lock.
*/
class GameSetupService : Service() {
private var lastNotified = 0L
private val listener: (GameSetup.State) -> Unit = { state ->
val now = SystemClock.elapsedRealtime()
if (state !is GameSetup.State.Working || now - lastNotified >= NOTIFY_INTERVAL_MS) {
lastNotified = now
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(state))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// startForegroundService obliges the service to go foreground even when it has nothing
// to do, or Android 12+ ends the app.
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)
val uri = intent?.data
if (uri == null || !GameSetup.begin(task)) {
// A task already going keeps the service, and ends it itself.
if (!GameSetup.isRunning) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf(startId)
}
return START_NOT_STICKY
}
GameSetup.addListener(listener)
val deleteSource = intent.getBooleanExtra(EXTRA_DELETE_SOURCE, false)
val wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS)
thread(name = "GameSetup") {
try {
GameSetup.run(applicationContext, task, uri, deleteSource)
} finally {
releaseReadPermission(uri)
if (wakeLock.isHeld) wakeLock.release()
mainExecutor.execute {
GameSetup.removeListener(listener)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
return START_NOT_STICKY
}
private fun releaseReadPermission(uri: Uri) {
if (uri.scheme != "content") return
try {
contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (e: SecurityException) {
// Never persisted: the provider does not offer persistable grants.
}
}
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java) ?: return
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, getString(R.string.disc_setup_channel), NotificationManager.IMPORTANCE_LOW),
)
}
private fun notification(state: GameSetup.State): Notification {
val builder = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_wheel)
.setContentTitle(getString(R.string.disc_setup_notification_title))
.setOngoing(true)
.setOnlyAlertOnce(true)
if (state is GameSetup.State.Working) {
val percent = if (state.total > 0) (state.done * 100 / state.total).toInt() else 0
val text = if (state.task == GameSetup.Task.ImportPackage) R.string.disc_setup_importing else R.string.disc_setup_extracting
builder.setContentText(getString(text, percent)).setProgress(100, percent, false)
} else {
builder.setContentText(getString(R.string.disc_setup_checking)).setProgress(0, 0, true)
}
return builder.build()
}
companion object {
private const val TAG = "WiiCompiledLauncher"
private const val CHANNEL_ID = "disc_setup"
private const val NOTIFICATION_ID = 1
private const val NOTIFY_INTERVAL_MS = 1000L
private const val EXTRA_TASK = "task"
private const val EXTRA_DELETE_SOURCE = "deleteSource"
// Longer than any real task; only a hung run would reach it.
private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000
/**
* Starts [task] on [uri]: a document the player picked, or a file in the Import folder,
* which [deleteSource] removes once it has been imported.
*/
fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) {
if (uri.scheme == "content") {
try {
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (e: SecurityException) {
Log.i(TAG, "No persistable read grant for $uri; relying on the picker's grant")
}
}
val intent = Intent(context, GameSetupService::class.java)
.setData(uri)
.putExtra(EXTRA_TASK, task.name)
.putExtra(EXTRA_DELETE_SOURCE, deleteSource)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startForegroundService(intent)
}
}
}
@@ -0,0 +1,407 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.app.ActivityManager
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Bundle
import android.text.format.Formatter
import android.util.Log
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.QuestActivity
import org.wiicompiled.quest.R
/**
* The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR),
* with a Home page that sets up and starts the game and a Settings page that edits Config.toml.
*
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
* their disc on a PC and brought over with Import from computer). Home's main button is always
* the next of those steps, and Play once both are there.
*
* The game runs as [QuestActivity], an immersive activity in its own `:game` process. SDL and the
* runtime cannot start twice in one process, so that process ends with every game session, and
* this panel stays behind to relaunch it.
*/
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 }
private lateinit var navHome: View
private lateinit var navSettings: View
private lateinit var homePage: View
private lateinit var settingsView: View
private lateinit var trails: WheelTrailsView
private lateinit var playButton: View
private lateinit var playIcon: ImageView
private lateinit var playText: TextView
private lateinit var progress: ProgressBar
private lateinit var homeStatus: TextView
private lateinit var secondary: TextView
private lateinit var cancel: View
private lateinit var dataBanner: View
private lateinit var dataBannerIcon: ImageView
private lateinit var dataBannerText: TextView
private lateinit var settings: SettingsPage
private var page = Page.Home
private var launching = false
private var trailsAway = true
private var setupKind: Class<*>? = null
private var mainAction = Action.Play
private var secondaryAction: Action? = null
private val setupListener: (GameSetup.State) -> Unit = { state ->
when {
page == Page.Home -> refreshHome()
// Rows only change when a task starts or ends, not with every progress step.
state.javaClass != setupKind -> settings.refresh()
}
setupKind = state.javaClass
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_launcher)
try {
GameStorage.prepare(this)
} catch (e: Exception) {
Log.w(TAG, "Cannot prepare the game directory", e)
}
navHome = findViewById(R.id.nav_home)
navSettings = findViewById(R.id.nav_settings)
homePage = findViewById(R.id.page_home)
settingsView = findViewById(R.id.page_settings)
trails = findViewById(R.id.home_trails)
playButton = findViewById(R.id.home_play)
playIcon = findViewById(R.id.home_play_icon)
playText = findViewById(R.id.home_play_text)
progress = findViewById(R.id.home_progress)
homeStatus = findViewById(R.id.home_status)
secondary = findViewById(R.id.home_secondary)
cancel = findViewById(R.id.home_cancel)
dataBanner = findViewById(R.id.home_data_banner)
dataBannerIcon = findViewById(R.id.home_data_banner_icon)
dataBannerText = findViewById(R.id.home_data_banner_text)
findViewById<TextView>(R.id.home_title).setText(
if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base,
)
findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME)
settings = SettingsPage(
this,
settingsView,
ConfigStore(GameStorage.configFile(this)),
gameRunning = ::isGameRunning,
selectDiscImage = ::selectDiscImage,
importGame = ::importGame,
)
savedInstanceState?.getString(KEY_TAB)?.let { name ->
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
}
navHome.setOnClickListener { showPage(Page.Home) }
navSettings.setOnClickListener { showPage(Page.Settings) }
playButton.setOnClickListener { perform(mainAction) }
secondary.setOnClickListener { secondaryAction?.let(::perform) }
cancel.setOnClickListener { GameSetup.cancel() }
val restored = savedInstanceState?.getString(KEY_PAGE)?.let { name -> Page.entries.firstOrNull { it.name == name } }
showPage(restored ?: Page.Home)
}
override fun onResume() {
super.onResume()
launching = false
importDroppedPackage()
setupKind = GameSetup.state.javaClass
refresh()
GameSetup.addListener(setupListener)
// The game's process can take a moment to go away after its activity
// closes, which would still read as running.
val runningAtResume = isGameRunning()
window.decorView.postDelayed({
if (!isFinishing && isGameRunning() != runningAtResume) refresh()
}, PROCESS_EXIT_GRACE_MS)
}
override fun onPause() {
GameSetup.removeListener(setupListener)
super.onPause()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_PAGE, page.name)
outState.putString(KEY_TAB, settings.tab.name)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
val uri = data?.data ?: return
if (resultCode != RESULT_OK) return
val task = when (requestCode) {
REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc
REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage
else -> return
}
GameSetupService.start(this, task, uri)
// Progress and the outcome are shown on Home, wherever the picker was opened from.
showPage(Page.Home)
}
private fun showPage(target: Page) {
page = target
navHome.isSelected = target == Page.Home
navSettings.isSelected = target == Page.Settings
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
if (target == Page.Home) {
trailsAway = true
}
refresh()
}
private fun refresh() {
when (page) {
Page.Home -> refreshHome()
Page.Settings -> settings.refresh()
}
}
private fun refreshHome() {
val disc = GameStorage.discDirectory(this).absolutePath
val discStatus = GameStorage.discStatus(this)
val gameStatus = GameLibrary.status(this)
val running = isGameRunning()
val setup = GameSetup.state
val settingUp = GameSetup.isRunning
val importing = setupTask(setup) == GameSetup.Task.ImportPackage
mainAction = when {
running -> Action.Resume
gameStatus != GameLibrary.Status.Ready -> Action.ImportGame
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
else -> Action.Play
}
// Without anything yet, a player with only a headset starts from their disc image.
secondaryAction = when {
settingUp || running -> null
mainAction == Action.ImportGame && discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
mainAction == Action.SelectDisc -> Action.ImportGame
else -> null
}
playButton.isEnabled = !settingUp
playIcon.setImageResource(if (!settingUp && (mainAction == Action.Play || mainAction == Action.Resume)) R.drawable.ic_play else R.drawable.ic_disc)
playText.text = when {
setup is GameSetup.State.Checking -> getString(if (importing) R.string.home_checking_package else R.string.home_checking)
setup is GameSetup.State.Working -> getString(if (importing) R.string.home_importing else R.string.home_extracting, percent(setup))
setup is GameSetup.State.Finishing -> getString(R.string.home_finishing)
else -> getString(label(mainAction))
}
secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE
secondaryAction?.let { secondary.setText(label(it)) }
progress.visibility = if (settingUp) View.VISIBLE else View.GONE
progress.isIndeterminate = setup !is GameSetup.State.Working
if (setup is GameSetup.State.Working && setup.total > 0) {
progress.progress = (setup.done * progress.max / setup.total).toInt()
}
cancel.visibility = if (settingUp && setup !is GameSetup.State.Finishing) View.VISIBLE else View.GONE
homeStatus.text = when {
setup is GameSetup.State.Working -> getString(
R.string.home_extract_progress,
Formatter.formatShortFileSize(this, setup.done),
Formatter.formatShortFileSize(this, setup.total),
)
setup is GameSetup.State.Checking -> getString(if (importing) R.string.home_checking_package_status else R.string.home_checking_status)
settingUp || setup is GameSetup.State.Failed -> ""
setup is GameSetup.State.Cancelled -> getString(R.string.home_setup_cancelled)
running -> getString(R.string.home_running)
mainAction == Action.Play && setup is GameSetup.State.Done -> getString(R.string.home_setup_done)
mainAction == Action.Play -> getString(R.string.home_put_on_headset)
else -> ""
}
when {
settingUp -> showBanner(null)
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)
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)
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
else -> showBanner(null)
}
if (trailsAway && !launching) {
trailsAway = false
trails.enter()
}
}
private fun label(action: Action): Int = when (action) {
Action.Play -> R.string.home_play
Action.Resume -> R.string.home_resume
Action.SelectDisc -> R.string.home_select_disc
Action.ImportGame -> R.string.home_import
}
private fun perform(action: Action) {
if (GameSetup.isRunning) return
when (action) {
Action.Play, Action.Resume -> play()
Action.SelectDisc -> selectDiscImage()
Action.ImportGame -> importGame()
}
}
private fun showBanner(text: String?, warning: Boolean = false) {
if (text == null) {
dataBanner.visibility = View.GONE
return
}
dataBannerText.text = text
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))
dataBanner.visibility = View.VISIBLE
}
/** Opens the document picker for a disc image, asking first when it would replace DATA. */
private fun selectDiscImage() {
if (GameSetup.isRunning) return
if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing) {
openPicker(REQUEST_DISC_IMAGE)
return
}
confirm(R.string.home_replace_title, R.string.home_replace_message, R.string.home_select_disc) {
openPicker(REQUEST_DISC_IMAGE)
}
}
/** Opens the document picker for a .wcgame, asking first when it would replace the game. */
private fun importGame() {
if (GameSetup.isRunning) return
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
openPicker(REQUEST_GAME_PACKAGE)
return
}
confirm(R.string.home_replace_game_title, R.string.home_replace_game_message, R.string.home_import) {
openPicker(REQUEST_GAME_PACKAGE)
}
}
private fun confirm(title: Int, message: Int, positive: Int, onConfirm: () -> Unit) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton(positive) { _, _ -> onConfirm() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun openPicker(requestCode: Int) {
// Disc images and game files have no reliable MIME type, so every file is offered
// and the task checks the name and contents.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
try {
@Suppress("DEPRECATION")
startActivityForResult(intent, requestCode)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No document picker", e)
Toast.makeText(this, R.string.home_no_picker, Toast.LENGTH_LONG).show()
}
}
/**
* Imports the newest .wcgame in the Import folder, where Build-QuestGame.ps1 -Install and adb
* put them. Each package is tried once, whether it imports or not, until it changes: a file
* adb pushed belongs to the shell user, so the app cannot always delete it afterwards.
*/
private fun importDroppedPackage() {
if (GameSetup.isRunning) return
val dropped = GameStorage.importDirectory(this)
.listFiles { file -> file.isFile && file.name.endsWith(".wcgame", ignoreCase = true) }
?.maxByOrNull { it.lastModified() }
?: return
val identity = "${dropped.absolutePath}:${dropped.length()}:${dropped.lastModified()}"
val preferences = getSharedPreferences(PREFERENCES, MODE_PRIVATE)
if (preferences.getString(KEY_LAST_DROPPED_IMPORT, null) == identity) return
preferences.edit().putString(KEY_LAST_DROPPED_IMPORT, identity).apply()
Log.i(TAG, "Importing dropped game package ${dropped.absolutePath}")
GameSetupService.start(this, GameSetup.Task.ImportPackage, Uri.fromFile(dropped), deleteSource = true)
}
private fun play() {
if (launching) {
return
}
launching = true
trailsAway = true
trails.leave {
try {
startActivity(Intent(this, QuestActivity::class.java))
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "Cannot start the game activity", e)
Toast.makeText(this, R.string.home_launch_failed, Toast.LENGTH_LONG).show()
launching = false
refreshHome()
}
}
}
private fun isGameRunning(): Boolean {
val manager = getSystemService(ActivityManager::class.java) ?: return false
val gameProcess = "$packageName$GAME_PROCESS_SUFFIX"
return manager.runningAppProcesses.orEmpty().any { it.processName == gameProcess }
}
private fun setupTask(state: GameSetup.State): GameSetup.Task? = when (state) {
is GameSetup.State.Checking -> state.task
is GameSetup.State.Working -> state.task
is GameSetup.State.Finishing -> state.task
is GameSetup.State.Done -> state.task
is GameSetup.State.Failed -> state.task
is GameSetup.State.Cancelled -> state.task
GameSetup.State.Idle -> null
}
private fun percent(state: GameSetup.State.Working): Int =
if (state.total > 0) (state.done * 100 / state.total).toInt() else 0
private companion object {
const val TAG = "WiiCompiledLauncher"
// Must match QuestActivity's android:process in AndroidManifest.xml.
const val GAME_PROCESS_SUFFIX = ":game"
const val KEY_PAGE = "page"
const val KEY_TAB = "settingsTab"
const val PROCESS_EXIT_GRACE_MS = 1000L
const val REQUEST_DISC_IMAGE = 1
const val REQUEST_GAME_PACKAGE = 2
const val PREFERENCES = "launcher"
const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport"
}
}
@@ -0,0 +1,37 @@
package org.wiicompiled.quest.launcher
import java.io.IOException
import java.io.InterruptedIOException
/**
* nod, the disc image library the PC installer runs as nodtool, through
* android/nod-jni. Every call takes the file descriptor of an open disc image
* (ISO, WBFS, RVZ, WIA, CISO, GCZ) and reads the Wii data partition from it.
* All three block, so callers run them off the main thread.
*/
object NodDisc {
init {
System.loadLibrary("nod_jni")
}
fun interface Listener {
/** Bytes written so far out of [total]; return false to cancel. */
fun update(done: Long, total: Long): Boolean
}
/** "<game id>\n<title>\n<bytes an extraction writes>", see [DiscChecks.parseHeader]. */
@Throws(IOException::class)
external fun header(fd: Int): String
/** `sys/main.dol` or a path below `files/` of the data partition. */
@Throws(IOException::class)
external fun readFile(fd: Int, path: String): ByteArray
/**
* Writes the data partition to [outDir] in nodtool extract's layout.
* Throws [InterruptedIOException] when [listener] cancels.
*/
@Throws(IOException::class)
external fun extract(fd: Int, outDir: String, listener: Listener)
}
@@ -0,0 +1,636 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.Switch
import android.widget.TextView
import android.widget.Toast
import kotlin.math.roundToInt
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.R
/**
* The launcher's Settings page: tabs of setting rows, each bound to one or two
* Config.toml keys.
*
* Every row reads its value the way runtime/include/runtime_config.h parses it,
* with the same default and the same accepted range, so what is shown is what
* the game will use. Nothing is written until the player changes a row, and
* each change is one line edit of a fresh read of the file.
*/
class SettingsPage(
private val activity: Activity,
root: View,
private val store: ConfigStore,
private val gameRunning: () -> Boolean,
private val selectDiscImage: () -> Unit,
private val importGame: () -> Unit,
) {
enum class Tab(val label: Int) {
Vr(R.string.settings_tab_vr),
Graphics(R.string.settings_tab_graphics),
Controls(R.string.settings_tab_controls),
Audio(R.string.settings_tab_audio),
About(R.string.settings_tab_about),
}
private class Dependent(val row: View, val control: View, val enabledIf: (TomlConfig) -> Boolean)
private val tabStrip: LinearLayout = root.findViewById(R.id.settings_tabs)
private val rows: LinearLayout = root.findViewById(R.id.settings_rows)
private val scroll: ScrollView = root.findViewById(R.id.settings_scroll)
private val dependents = mutableListOf<Dependent>()
private val tabViews = mutableMapOf<Tab, TextView>()
/** The file as read for the rows on screen. */
private var current = TomlConfig.parse("")
var tab: Tab = Tab.Vr
private set
init {
for (entry in Tab.entries) {
val view = TextView(activity, null, 0, R.style.Launcher_Tab).apply {
setText(entry.label)
setOnClickListener { select(entry) }
}
tabStrip.addView(view, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(44)))
tabViews[entry] = view
}
tabViews.getValue(tab).isSelected = true
}
fun select(entry: Tab) {
if (entry != tab) {
tabViews.getValue(tab).isSelected = false
tab = entry
tabViews.getValue(tab).isSelected = true
scroll.scrollTo(0, 0)
}
refresh()
}
/** Rebuilds the open tab from the file, which the headset panel may have changed. */
fun refresh() {
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)
}
when (tab) {
Tab.Vr -> buildVr()
Tab.Graphics -> buildGraphics()
Tab.Controls -> buildControls()
Tab.Audio -> buildAudio()
Tab.About -> buildAbout()
}
updateDependents(current)
}
private fun buildVr() {
val firstPerson = { c: TomlConfig -> c.bool("vr", "first_person") ?: false }
section(R.string.section_vr_camera) {
choice(
R.string.vr_camera, R.string.vr_camera_helper,
listOf(R.string.vr_camera_chase, R.string.vr_camera_first_person),
read = { if (firstPerson(it)) 1 else 0 },
write = { c, index -> c.setBool("vr", "first_person", index == 1) },
)
choice(
R.string.vr_rotation, R.string.vr_rotation_helper,
listOf(R.string.vr_rotation_yaw, R.string.vr_rotation_yaw_pitch, R.string.vr_rotation_full),
read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS) },
write = { c, index -> c.setString("vr", "first_person_rotation", ROTATIONS[index]) },
enabledIf = firstPerson,
)
// One setting in two keys, presented as the in-headset panel's two
// tick boxes are: nothing, the driver (model 0), or every model.
choice(
R.string.vr_hide, R.string.vr_hide_helper,
listOf(R.string.vr_hide_nothing, R.string.vr_hide_driver, R.string.vr_hide_driver_and_kart),
read = {
val hide = it.bool("vr", "first_person_hide_driver") ?: true
val model = it.integer("vr", "first_person_hidden_model")?.takeIf { m -> m in -1L..31L } ?: 0L
when {
!hide -> 0
model < 0 -> 2
else -> 1
}
},
write = { c, index ->
c.setBool("vr", "first_person_hide_driver", index != 0)
if (index != 0) {
c.setInteger("vr", "first_person_hidden_model", if (index == 1) 0L else -1L)
}
},
enabledIf = firstPerson,
)
slider(
R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0,
read = { number(it, "vr", "lean_back_degrees", -45.0, 45.0, 0.0) },
format = { "%.0f°".format(it) },
write = { c, value -> c.setFloat("vr", "lean_back_degrees", value) },
)
}
section(R.string.section_vr_headset) {
slider(
R.string.vr_render_scale, R.string.vr_render_scale_helper, 0.25, 2.0, 0.05,
read = { number(it, "vr", "render_scale", 0.25, 2.0, 1.0) },
format = { "%.2f×".format(it) },
write = { c, value -> c.setFloat("vr", "render_scale", value) },
)
choice(
R.string.vr_interpolation, R.string.vr_interpolation_helper,
listOf(activity.getString(R.string.vr_interpolation_off), activity.getString(R.string.vr_interpolation_auto), "72 FPS", "90 FPS", "120 FPS"),
read = { INTERPOLATION_FPS.indexOf(vrInterpolationFps(it)) },
write = { c, index -> c.setInteger("vr", "frame_interpolation_fps", INTERPOLATION_FPS[index]) },
)
}
section(R.string.section_vr_screen) {
toggle(
R.string.vr_hud_screen, R.string.vr_hud_screen_helper,
read = { it.bool("vr", "hud_virtual_screen") ?: true },
write = { c, value -> c.setBool("vr", "hud_virtual_screen", value) },
)
slider(
R.string.vr_hud_distance, R.string.vr_hud_distance_helper, 0.5, 5.0, 0.1,
read = { number(it, "vr", "hud_distance_meters", 0.25, 10.0, 2.0) },
format = { "%.1f m".format(it) },
write = { c, value -> c.setFloat("vr", "hud_distance_meters", value) },
)
slider(
R.string.vr_hud_width, R.string.vr_hud_width_helper, 1.0, 6.0, 0.1,
read = { number(it, "vr", "hud_width_meters", 0.25, 20.0, 2.4) },
format = { "%.1f m".format(it) },
write = { c, value -> c.setFloat("vr", "hud_width_meters", value) },
)
}
}
private fun buildGraphics() {
section(R.string.section_graphics) {
choice(
R.string.graphics_resolution, R.string.graphics_resolution_helper,
RESOLUTIONS.map { if (it == 1.0) activity.getString(R.string.graphics_resolution_native) else "${TomlConfig.formatFloat(it).removeSuffix(".0")}x" },
read = { RESOLUTIONS.indexOf(resolution(it)) },
write = { c, index -> c.setFloat("video", "resolution_multiplier", RESOLUTIONS[index]) },
custom = { if (resolution(it) == 0.0) "Auto" else "${TomlConfig.formatFloat(resolution(it)).removeSuffix(".0")}x" },
)
toggle(
R.string.graphics_widescreen, R.string.graphics_widescreen_helper,
read = { it.bool("video", "widescreen") ?: true },
write = { c, value -> c.setBool("video", "widescreen", value) },
)
toggle(
R.string.graphics_bloom, R.string.graphics_bloom_helper,
read = { (disabledPostProcessing(it) and BLOOM_PATH) != 0L },
write = { c, value -> c.setInteger("video", "disabled_post_processing_paths", if (value) BLOOM_PATH else 0L) },
)
toggle(
R.string.graphics_skip_unready, R.string.graphics_skip_unready_helper,
read = { it.bool("video", "skip_unready_pipelines") ?: true },
write = { c, value -> c.setBool("video", "skip_unready_pipelines", value) },
)
}
}
private fun buildControls() {
section(R.string.section_controls) {
choice(
R.string.controls_mode, R.string.controls_mode_helper,
listOf(R.string.controls_mode_wii_remote, R.string.controls_mode_gamepad),
read = { stringIndex(it, "vr", "controller_mode", CONTROLLER_MODES) },
write = { c, index -> c.setString("vr", "controller_mode", CONTROLLER_MODES[index]) },
)
toggle(
R.string.controls_rumble, R.string.controls_rumble_helper,
read = { it.bool("controller", "rumble") ?: true },
write = { c, value -> c.setBool("controller", "rumble", value) },
)
}
section(R.string.section_controls_mapping) {
info(R.string.controls_map_a, activity.getString(R.string.controls_map_a_value))
info(R.string.controls_map_b, activity.getString(R.string.controls_map_b_value))
info(R.string.controls_map_12, activity.getString(R.string.controls_map_12_value))
info(R.string.controls_map_minus_plus, activity.getString(R.string.controls_map_minus_plus_value))
info(R.string.controls_map_stick, activity.getString(R.string.controls_map_stick_value))
info(R.string.controls_map_z, activity.getString(R.string.controls_map_z_value))
info(R.string.controls_map_c, activity.getString(R.string.controls_map_c_value))
info(R.string.controls_map_home, activity.getString(R.string.controls_map_home_value))
info(R.string.controls_map_pointer, activity.getString(R.string.controls_map_pointer_value))
info(R.string.controls_map_panel, activity.getString(R.string.controls_map_panel_value))
}
}
private fun buildAudio() {
section(R.string.section_audio) {
for ((label, key) in VOLUMES) {
slider(
label, null, 0.0, 100.0, 1.0,
read = { number(it, "audio", key, 0.0, 1.0, 1.0) * 100.0 },
format = { "%.0f%%".format(it) },
write = { c, value -> c.setFloat("audio", key, value / 100.0) },
)
}
toggle(
R.string.audio_muted, R.string.audio_muted_helper,
read = { it.bool("audio", "muted") ?: false },
write = { c, value -> c.setBool("audio", "muted", value) },
)
}
}
private fun buildAbout() {
section(R.string.section_about_app) {
info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE))
}
section(R.string.section_about_storage) {
val disc = GameStorage.discDirectory(activity).absolutePath
val status = when (GameStorage.discStatus(activity)) {
GameStorage.DiscStatus.Ready -> R.string.about_game_data_ready
GameStorage.DiscStatus.Incomplete -> R.string.about_game_data_incomplete
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) {
selectDiscImage()
}
val manifest = GameLibrary.manifest(activity)
val gameText = when (GameLibrary.status(activity)) {
GameLibrary.Status.Ready -> manifest?.let {
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
} ?: activity.getString(R.string.about_game_missing)
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
}
info(R.string.about_game, gameText, stacked = true)
action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) {
importGame()
}
info(R.string.about_config, store.file.absolutePath, stacked = true)
info(R.string.about_logs, GameStorage.logsDirectory(activity).absolutePath, stacked = true)
}
section(R.string.section_about_diagnostics) {
toggle(
R.string.about_openxr_logging, R.string.about_openxr_logging_helper,
read = { it.bool("diagnostics", "openxr_logging") ?: false },
write = { c, value -> c.setBool("diagnostics", "openxr_logging", value) },
)
}
}
// Row construction
private inner class Section {
private val config = current
val views = mutableListOf<View>()
fun toggle(
title: Int,
helper: Int?,
read: (TomlConfig) -> Boolean,
write: (TomlConfig, Boolean) -> Unit,
enabledIf: ((TomlConfig) -> Boolean)? = null,
) {
val control = Switch(activity).apply {
isChecked = read(config)
thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300)
trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600)
setOnCheckedChangeListener { _, checked -> commit { write(it, checked) } }
}
val row = row(title, helper, control)
row.setOnClickListener { if (control.isEnabled) control.toggle() }
add(row, control, enabledIf)
}
/** [read] returns the option index, or -1 when the file holds a value no option names. */
fun choice(
title: Int,
helper: Int?,
labels: List<Any>,
read: (TomlConfig) -> Int,
write: (TomlConfig, Int) -> Unit,
enabledIf: ((TomlConfig) -> Boolean)? = null,
custom: ((TomlConfig) -> String)? = null,
) {
val names = labels.map { if (it is Int) activity.getString(it) else it.toString() }.toMutableList()
var shown = read(config)
if (shown < 0) {
// A value only a hand edit can set: show it rather than pretend
// it is one of the options, and keep it until the player picks one.
names += activity.getString(R.string.settings_custom_value, custom?.invoke(config) ?: "?")
shown = names.size - 1
}
val spinner = Spinner(activity, Spinner.MODE_DROPDOWN).apply {
background = drawable(R.drawable.bg_dropdown)
setPopupBackgroundDrawable(drawable(R.drawable.bg_dropdown_popup))
dropDownVerticalOffset = dp(4)
adapter = ArrayAdapter(activity, R.layout.item_dropdown, names).apply {
setDropDownViewResource(R.layout.item_dropdown_popup)
}
setSelection(shown, false)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (position == shown || position >= labels.size) {
return
}
shown = position
commit { write(it, position) }
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
layoutParams = LinearLayout.LayoutParams(dp(210), LinearLayout.LayoutParams.WRAP_CONTENT)
}
add(row(title, helper, spinner), spinner, enabledIf)
}
fun slider(
title: Int,
helper: Int?,
min: Double,
max: Double,
step: Double,
read: (TomlConfig) -> Double,
format: (Double) -> String,
write: (TomlConfig, Double) -> Unit,
enabledIf: ((TomlConfig) -> Boolean)? = null,
) {
val initial = read(config)
var committed = initial
val valueText = TextView(activity).apply {
text = format(initial)
setTextColor(activity.getColor(R.color.neutral_100))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
gravity = Gravity.END or Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(dp(64), LinearLayout.LayoutParams.WRAP_CONTENT)
}
val steps = ((max - min) / step).roundToInt()
val seekBar = SeekBar(activity).apply {
this.max = steps
progress = ((initial.coerceIn(min, max) - min) / 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 commitValue(value: Double) {
if (value != committed) {
committed = value
commit { write(it, value) }
}
}
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
private var tracking = false
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
if (!fromUser) return
valueText.text = format(valueAt(progress))
// Dragging writes once, on release; a key or controller step writes at once.
if (!tracking) commitValue(valueAt(progress))
}
override fun onStartTrackingTouch(bar: SeekBar) {
tracking = true
}
override fun onStopTrackingTouch(bar: SeekBar) {
tracking = false
commitValue(valueAt(bar.progress))
}
})
val control = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(seekBar)
addView(valueText)
}
add(row(title, helper, control), seekBar, enabledIf)
}
fun action(title: Int, helper: Int?, button: Int, enabled: Boolean, onClick: () -> Unit) {
val control = TextView(activity).apply {
setText(button)
setTextColor(activity.getColor(R.color.neutral_100))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
gravity = Gravity.CENTER
background = drawable(R.drawable.bg_button_secondary)
setPadding(dp(16), 0, dp(16), 0)
minHeight = dp(40)
isEnabled = enabled
alpha = if (enabled) 1f else 0.45f
setOnClickListener { onClick() }
}
add(row(title, helper, control), null, null)
}
/** A read-only line; [stacked] puts a long value such as a path under its title. */
fun info(title: Int, value: String, stacked: Boolean = false) {
val row: LinearLayout
if (stacked) {
row = row(title, null, null)
val texts = row.getChildAt(0) as LinearLayout
texts.addView(helperText(value, selectable = true))
} else {
val valueText = TextView(activity).apply {
text = value
setTextColor(activity.getColor(R.color.neutral_300))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
}
row = row(title, null, valueText)
row.minimumHeight = dp(44)
}
add(row, null, null)
}
private fun add(row: View, control: View?, enabledIf: ((TomlConfig) -> Boolean)?) {
views += row
if (control != null && enabledIf != null) {
dependents += Dependent(row, control, enabledIf)
}
}
}
private fun section(label: Int, build: Section.() -> Unit) {
rows.addView(
TextView(activity, null, 0, R.style.Launcher_SectionLabel).apply {
setText(label)
},
LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = dp(18)
bottomMargin = dp(6)
},
)
val section = Section().apply(build)
section.views.forEachIndexed { index, view ->
val last = section.views.size - 1
view.background = drawable(
when {
last == 0 -> R.drawable.bg_row_single
index == 0 -> R.drawable.bg_row_top
index == last -> R.drawable.bg_row_bottom
else -> R.drawable.bg_row_middle
},
)
rows.addView(
view,
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
if (index > 0) topMargin = dp(3)
},
)
}
}
private fun row(title: Int, helper: Int?, control: View?): LinearLayout {
val texts = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
addView(
TextView(activity).apply {
setText(title)
setTextColor(activity.getColor(R.color.neutral_100))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
},
)
if (helper != null) {
addView(helperText(activity.getString(helper), selectable = false))
}
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
minimumHeight = dp(58)
setPadding(dp(16), dp(10), dp(14), dp(10))
addView(texts, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { marginEnd = dp(16) })
if (control != null) {
addView(control)
}
}
}
private fun helperText(text: String, selectable: Boolean) = TextView(activity).apply {
this.text = text
setTextColor(activity.getColor(R.color.neutral_400))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f)
setTextIsSelectable(selectable)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = dp(2)
}
}
private fun banner(background: Int, text: Int) {
val warning = background == R.drawable.bg_banner_warning
val content = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
this.background = drawable(background)
setPadding(dp(12), dp(10), dp(14), dp(10))
if (warning) {
addView(
ImageView(activity).apply {
setImageResource(R.drawable.ic_warning)
imageTintList = ColorStateList.valueOf(activity.getColor(R.color.warning_500))
},
LinearLayout.LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(12) },
)
}
addView(
TextView(activity).apply {
setText(text)
setTextColor(activity.getColor(R.color.neutral_300))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
},
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f),
)
}
rows.addView(content)
}
private fun commit(edit: (TomlConfig) -> Unit) {
var edited: TomlConfig? = null
val saved = store.update {
edit(it)
edited = it
}
if (!saved) {
Toast.makeText(activity, R.string.settings_write_failed, Toast.LENGTH_LONG).show()
}
edited?.let(::updateDependents)
}
private fun updateDependents(config: TomlConfig) {
for (dependent in dependents) {
val enabled = dependent.enabledIf(config)
dependent.control.isEnabled = enabled
dependent.row.alpha = if (enabled) 1f else 0.45f
}
}
private fun checkedColors(checked: Int, unchecked: Int) = ColorStateList(
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
intArrayOf(activity.getColor(checked), activity.getColor(unchecked)),
)
private fun drawable(id: Int): Drawable = activity.getDrawable(id)!!
private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt()
private companion object {
val ROTATIONS = listOf("yaw", "yaw_pitch", "full")
val CONTROLLER_MODES = listOf("wii_remote", "gamepad")
val INTERPOLATION_FPS = listOf(0L, 1L, 72L, 90L, 120L)
val RESOLUTIONS = listOf(1.0, 1.5, 2.0, 3.0, 4.0)
val SUPPORTED_RESOLUTIONS = listOf(0.0, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0)
const val BLOOM_PATH = 0x10L
const val UINT32_MAX = 0xFFFF_FFFFL
val VOLUMES = listOf(
R.string.audio_master to "volume",
R.string.audio_music to "music_volume",
R.string.audio_sound_effects to "sound_effects_volume",
R.string.audio_voices to "voices_volume",
R.string.audio_ui to "ui_volume",
)
/** A value outside what the runtime accepts reads as the runtime's default. */
fun number(config: TomlConfig, section: String, key: String, min: Double, max: Double, default: Double): Double =
config.number(section, key)?.takeIf { it in min..max } ?: default
/** Unrecognised strings fall back to the first (default) option, as in the runtime. */
fun stringIndex(config: TomlConfig, section: String, key: String, values: List<String>): Int =
values.indexOf(config.string(section, key)).coerceAtLeast(0)
fun resolution(config: TomlConfig): Double =
config.number("video", "resolution_multiplier")?.takeIf { it in SUPPORTED_RESOLUTIONS } ?: 1.0
/** RuntimeUserConfig's disabledPostProcessingPaths: only the bloom bit is accepted. */
fun disabledPostProcessing(config: TomlConfig): Long =
config.integer("video", "disabled_post_processing_paths")
?.takeIf { it in 0L..UINT32_MAX && (it and BLOOM_PATH.inv()) == 0L } ?: BLOOM_PATH
/** vr.frame_interpolation_fps, with the legacy frame_interpolation switch and NormalizeFrameInterpolationFps. */
fun vrInterpolationFps(config: TomlConfig): Long {
val value = config.integer("vr", "frame_interpolation_fps")?.takeIf { it in 0L..UINT32_MAX }
?: config.bool("vr", "frame_interpolation")?.let { if (it) 1L else 0L }
?: 0L
return if (value in INTERPOLATION_FPS) value else 0L
}
}
}
@@ -0,0 +1,227 @@
package org.wiicompiled.quest.launcher
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
/**
* The runtime's Config.toml held as lines, read and edited one flat
* `key = value` at a time so every comment, unknown key and path the runtime,
* the in-headset settings panel or the player put there survives.
*
* Edits follow RuntimeConfigFile::WriteSetting (runtime/include/runtime_config.h)
* line for line: a present key is replaced where it stands, a missing key goes
* after its section's last non-blank line, and a missing section is appended.
* The typed readers accept only what the runtime's toml11 lookups accept, so a
* value the game would ignore reads as absent here too and shows its default.
*/
class TomlConfig private constructor(private val lines: MutableList<String>) {
private class Section(val header: Int, val end: Int)
fun text(): String = if (lines.isEmpty()) "" else lines.joinToString("\n", postfix = "\n")
/** The value of [section].[key] as written, without a trailing comment. */
fun literal(section: String, key: String): String? {
val found = findSection(section) ?: return null
val index = findKey(found, key) ?: return null
val code = removeComment(lines[index])
return code.substring(code.indexOf('=') + 1).trim().ifEmpty { null }
}
fun bool(section: String, key: String): Boolean? = when (literal(section, key)) {
"true" -> true
"false" -> false
else -> null
}
/** An integer literal; the runtime's integer keys reject floats. */
fun integer(section: String, key: String): Long? {
val literal = literal(section, key) ?: return null
return if (INTEGER.matches(literal)) literal.replace("_", "").toLongOrNull() else null
}
/** An integer or float literal, as FindConfigFloat accepts either. */
fun number(section: String, key: String): Double? {
val literal = literal(section, key) ?: return null
if (!INTEGER.matches(literal) && !FLOAT.matches(literal)) {
return null
}
return literal.replace("_", "").toDoubleOrNull()?.takeIf { it.isFinite() }
}
fun string(section: String, key: String): String? = literal(section, key)?.let(::unquote)
fun set(section: String, key: String, literal: String) {
val replacement = "$key = $literal"
val found = findSection(section)
if (found == null) {
if (lines.isNotEmpty() && lines.last().isNotEmpty()) {
lines.add("")
}
lines.add("[$section]")
lines.add(replacement)
return
}
val index = findKey(found, key)
if (index != null) {
lines[index] = replacement
return
}
// Keep the key above the blank line that separates this section from
// the next header, where a reader would take it for the next section's.
var insertAt = found.end
while (insertAt > found.header + 1 && lines[insertAt - 1].trim().isEmpty()) {
insertAt--
}
lines.add(insertAt, replacement)
}
fun setBool(section: String, key: String, value: Boolean) = set(section, key, value.toString())
fun setInteger(section: String, key: String, value: Long) = set(section, key, value.toString())
fun setFloat(section: String, key: String, value: Double) = set(section, key, formatFloat(value))
fun setString(section: String, key: String, value: String) = set(section, key, quote(value))
private fun findSection(section: String): Section? {
var header = -1
for (i in lines.indices) {
val name = headerName(lines[i]) ?: continue
if (header >= 0) {
return Section(header, i)
}
if (name == section) {
header = i
}
}
return if (header >= 0) Section(header, lines.size) else null
}
private fun findKey(section: Section, key: String): Int? {
for (i in section.header + 1 until section.end) {
val code = removeComment(lines[i]).trim()
val equals = code.indexOf('=')
if (equals >= 0 && code.substring(0, equals).trim() == key) {
return i
}
}
return null
}
companion object {
private val INTEGER = Regex("[+-]?\\d(_?\\d)*")
private val FLOAT = Regex("[+-]?\\d(_?\\d)*(\\.\\d(_?\\d)*)?([eE][+-]?\\d(_?\\d)*)?")
fun parse(text: String): TomlConfig {
val lines = text.split('\n').map { it.removeSuffix("\r") }.toMutableList()
if (lines.last().isEmpty()) {
lines.removeAt(lines.size - 1)
}
return TomlConfig(lines)
}
/** The line up to a `#` that is not inside a quoted string. */
fun removeComment(line: String): String {
var inSingle = false
var inDouble = false
var escaped = false
for (i in line.indices) {
val ch = line[i]
if (inDouble && ch == '\\' && !escaped) {
escaped = true
continue
}
when {
ch == '\'' && !inDouble -> inSingle = !inSingle
ch == '"' && !inSingle && !escaped -> inDouble = !inDouble
ch == '#' && !inSingle && !inDouble -> return line.substring(0, i)
}
escaped = false
}
return line
}
private fun headerName(line: String): String? {
val code = removeComment(line).trim()
if (code.length < 2 || code.first() != '[' || code.last() != ']') {
return null
}
return code.substring(1, code.length - 1).trim()
}
/** Three decimals at most, and always a decimal point so TOML reads a float. */
fun formatFloat(value: Double): String {
val text = BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
return if (text.contains('.')) text else "$text.0"
}
fun quote(value: String): String = buildString {
append('"')
for (ch in value) {
when (ch) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (ch < ' ' || ch == '') append(String.format(Locale.ROOT, "\\u%04x", ch.code)) else append(ch)
}
}
append('"')
}
/** A TOML basic or literal string's content, or null for anything else. */
fun unquote(literal: String): String? {
if (literal.length >= 2 && literal.first() == '\'' && literal.last() == '\'') {
val content = literal.substring(1, literal.length - 1)
return if (content.contains('\'')) null else content
}
if (literal.length < 2 || literal.first() != '"' || literal.last() != '"') {
return null
}
val result = StringBuilder()
var i = 1
val end = literal.length - 1
while (i < end) {
val ch = literal[i]
if (ch == '"') {
return null
}
if (ch != '\\') {
result.append(ch)
i++
continue
}
if (i + 1 >= end) {
return null
}
when (val escape = literal[i + 1]) {
'b' -> result.append('\b')
't' -> result.append('\t')
'n' -> result.append('\n')
'f' -> result.append(' ')
'r' -> result.append('\r')
'"' -> result.append('"')
'\\' -> result.append('\\')
'u', 'U' -> {
val digits = if (escape == 'u') 4 else 8
if (i + 2 + digits > end) {
return null
}
val code = literal.substring(i + 2, i + 2 + digits).toIntOrNull(16) ?: return null
if (!Character.isValidCodePoint(code)) {
return null
}
result.appendCodePoint(code)
i += digits
}
else -> return null
}
i += 2
}
return result.toString()
}
}
}
@@ -0,0 +1,140 @@
package org.wiicompiled.quest.launcher
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import org.wiicompiled.quest.R
/**
* The Home page's backdrop: WheelWizard's wheel trails, broad diagonal bands
* that each end in a wheel. They roll in when the page opens and roll away
* when Play is pressed.
*
* A trail is anchored at its wheel and its band runs from there off the edge.
* Rolling moves the wheel along the band, so an entering or leaving trail
* always comes from or goes to the edge it is attached to.
*/
class WheelTrailsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
private class Trail(
val fromRight: Boolean,
val xDp: Float,
val fromBottom: Boolean,
val yDp: Float,
/** Clockwise from straight down, the direction the band runs. */
val angle: Float,
val band: Int,
val wheel: Int,
)
private val trails = listOf(
Trail(false, 30f, false, -20f, 45f, R.color.primary_400, R.color.primary_700),
Trail(false, 210f, false, 10f, 45f, R.color.primary_600, R.color.primary_800),
Trail(false, 230f, false, 210f, 45f, R.color.primary_200, R.color.primary_600),
Trail(true, 230f, true, 60f, -35f, R.color.primary_400, R.color.primary_700),
Trail(true, 40f, true, 10f, -35f, R.color.primary_600, R.color.primary_800),
)
private val density = resources.displayMetrics.density
private val bandWidth = 137f * density
private val bandLength = 3000f * density
private val bandPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val wheelPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = RectF()
/** How far every wheel sits from its anchor along its band, in pixels. */
private var offset = 0f
private var rotationDegrees = 0f
private var animator: ValueAnimator? = null
/** Rolls the trails in from the edges. */
fun enter() {
animate(from = 320f * density, to = 0f, spin = 560f, durationMs = 650L, leaving = false, done = null)
}
/** Rolls the trails off the edges, then runs [done]. */
fun leave(done: () -> Unit) {
animate(from = offset, to = 1400f * density, spin = 360f, durationMs = 450L, leaving = true, done = done)
}
private fun animate(from: Float, to: Float, spin: Float, durationMs: Long, leaving: Boolean, done: (() -> Unit)?) {
animator?.cancel()
val startRotation = if (leaving) rotationDegrees else -200f
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = durationMs
interpolator = if (leaving) AccelerateInterpolator(1.6f) else DecelerateInterpolator(2f)
addUpdateListener {
val t = it.animatedValue as Float
offset = from + (to - from) * t
rotationDegrees = startRotation + spin * t
invalidate()
}
if (done != null) {
addListener(object : android.animation.AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(animation: android.animation.Animator) {
cancelled = true
}
override fun onAnimationEnd(animation: android.animation.Animator) {
if (!cancelled) done()
}
})
}
start()
}
}
override fun onDetachedFromWindow() {
animator?.cancel()
super.onDetachedFromWindow()
}
override fun onDraw(canvas: Canvas) {
for (trail in trails) {
val x = if (trail.fromRight) width - trail.xDp * density else trail.xDp * density
val y = if (trail.fromBottom) height - trail.yDp * density else trail.yDp * density
canvas.save()
canvas.translate(x, y)
canvas.rotate(trail.angle)
canvas.translate(0f, offset)
drawTrail(canvas, trail)
canvas.restore()
}
}
private fun drawTrail(canvas: Canvas, trail: Trail) {
val half = bandWidth / 2f
bandPaint.color = context.getColor(trail.band)
wheelPaint.color = context.getColor(trail.wheel)
rect.set(-half, -half, half, bandLength)
canvas.drawRoundRect(rect, half, half, bandPaint)
// Tyre, rim and hub, the same wheel as ic_wheel at band scale.
canvas.rotate(rotationDegrees)
val unit = bandWidth / 24f * 0.85f
canvas.drawCircle(0f, 0f, 11f * unit, wheelPaint)
canvas.drawCircle(0f, 0f, 8f * unit, bandPaint)
canvas.drawCircle(0f, 0f, 6.5f * unit, wheelPaint)
for (spoke in 0 until 5) {
val radians = Math.toRadians(-90.0 + 72.0 * spoke)
canvas.drawCircle(
(3.9f * unit * Math.cos(radians)).toFloat(),
(3.9f * unit * Math.sin(radians)).toFloat(),
1.45f * unit,
bandPaint,
)
}
canvas.drawCircle(0f, 0f, 0.9f * unit, bandPaint)
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/primary_400" android:state_selected="true" />
<item android:color="@color/neutral_50" android:state_hovered="true" />
<item android:color="@color/neutral_300" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/neutral_400" android:state_enabled="false" />
<item android:color="@color/neutral_50" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/neutral_50" android:state_selected="true" />
<item android:color="@color/neutral_200" android:state_hovered="true" />
<item android:color="@color/neutral_400" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<stroke android:width="1dp" android:color="@color/neutral_700" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="1dp" android:color="@color/warning_500" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<corners android:radius="6dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_600" />
<corners android:radius="6dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="1dp" android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_950" />
<corners android:topLeftRadius="14dp" android:bottomLeftRadius="14dp" />
</shape>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<selector>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<corners android:radius="6dp" />
</shape>
</item>
</selector>
</item>
<item
android:width="16dp"
android:height="16dp"
android:end="10dp"
android:drawable="@drawable/ic_chevron_down"
android:gravity="end|center_vertical" />
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<stroke android:width="1dp" android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The tall card behind the game title: a half-circle top on a 250dp width. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<corners android:topLeftRadius="125dp" android:topRightRadius="125dp" />
</shape>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Sidebar entry: the open page takes the content's colour, as in WheelWizard's menu. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_950" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:drawable="@android:color/transparent" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/neutral_600" />
<corners android:radius="999dp" />
</shape>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="6dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/primary_800" />
<corners android:radius="6dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/primary_600" />
<corners android:radius="6dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/primary_700" />
<corners android:radius="6dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<corners android:bottomLeftRadius="8dp" android:bottomRightRadius="8dp" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/neutral_900" />
<corners android:topLeftRadius="8dp" android:topRightRadius="8dp" />
</shape>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Settings tab: an underline marks the open tab. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<layer-list>
<item android:gravity="bottom" android:height="3dp">
<shape android:shape="rectangle">
<solid android:color="@color/primary_400" />
<corners android:radius="2dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_hovered="true">
<layer-list>
<item android:gravity="bottom" android:height="3dp">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_600" />
<corners android:radius="2dp" />
</shape>
</item>
</layer-list>
</item>
<item android:drawable="@android:color/transparent" />
</selector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="@color/neutral_300"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="2.5"
android:pathData="M6,9L12,15L18,9" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- An optical disc with its centre hole and a read-side highlight. Drawn for this app. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M1.5,12a10.5,10.5 0 1,0 21,0a10.5,10.5 0 1,0 -21,0z
M9.5,12a2.5,2.5 0 1,0 5,0a2.5,2.5 0 1,0 -5,0z
M12,4.5A7.5,7.5 0 0,0 4.5,12H6A6,6 0 0,1 12,6z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:strokeColor="#FFFFFFFF"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:pathData="M12,3L2.5,11.5H5V20.5H10V15H14V20.5H19V11.5H21.5Z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:strokeColor="#FFFFFFFF"
android:strokeLineJoin="round"
android:strokeWidth="2.5"
android:pathData="M7,4.5L19.5,12L7,19.5Z" />
</vector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeWidth="2"
android:pathData="M3.5,6H20.5M3.5,12H20.5M3.5,18H20.5" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12.4,6a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z
M5.9,12a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z
M10.4,18a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M12,2.5L22.5,20.5H1.5Z M11,9H13V15H11Z M11,16.5H13V18.5H11Z" />
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A kart wheel: tyre, rim with five holes, hub. Drawn for this app. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M1,12a11,11 0 1,0 22,0a11,11 0 1,0 -22,0z M4,12a8,8 0 1,0 16,0a8,8 0 1,0 -16,0z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.5,12a6.5,6.5 0 1,0 13,0a6.5,6.5 0 1,0 -13,0z
M10.55,8.1a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
M14.26,10.8a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
M12.84,15.16a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
M8.26,15.16a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
M6.84,10.8a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
M11.1,12a0.9,0.9 0 1,0 1.8,0a0.9,0.9 0 1,0 -1.8,0z" />
</vector>
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher panel: WheelWizard's layout, a sidebar of pages beside the open page. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/neutral_900"
android:orientation="horizontal">
<LinearLayout
android:layout_width="210dp"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="12dp"
android:paddingEnd="10dp"
android:paddingBottom="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingBottom="20dp">
<ImageView
android:layout_width="26dp"
android:layout_height="26dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_wheel"
android:tint="@color/neutral_400" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/app_name"
android:textColor="@color/neutral_300"
android:textSize="16sp" />
</LinearLayout>
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginBottom="6dp"
android:text="@string/launcher_section_general" />
<LinearLayout
android:id="@+id/nav_home"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_home" />
<TextView
style="@style/Launcher.NavLabel"
android:text="@string/launcher_nav_home" />
</LinearLayout>
<LinearLayout
android:id="@+id/nav_settings"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_sliders" />
<TextView
style="@style/Launcher.NavLabel"
android:text="@string/launcher_nav_settings" />
</LinearLayout>
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/launcher_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/bg_pill"
android:paddingStart="12dp"
android:paddingTop="4dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp"
android:textColor="@color/neutral_300"
android:textSize="13sp" />
</LinearLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_content"
android:clipToOutline="true">
<include
android:id="@+id/page_home"
layout="@layout/page_home" />
<include
android:id="@+id/page_settings"
layout="@layout/page_settings"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="40dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:paddingStart="12dp"
android:paddingEnd="34dp"
android:textColor="@color/neutral_100"
android:textSize="14sp" />
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/bg_nav_item"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:textColor="@color/neutral_100"
android:textSize="14sp" />
@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="64dp"
android:gravity="bottom"
android:paddingBottom="10dp"
android:text="@string/launcher_nav_home" />
<View
android:layout_width="match_parent"
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"
android:layout_marginStart="-24dp"
android:layout_marginEnd="-24dp"
android:layout_weight="1"
android:clipChildren="true">
<org.wiicompiled.quest.launcher.WheelTrailsView
android:id="@+id/home_trails"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="250dp"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:layout_marginTop="28dp"
android:background="@drawable/bg_home_card"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingEnd="10dp">
<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginTop="25dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_wheel"
android:tint="@color/neutral_600" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/home_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:textColor="@color/neutral_300"
android:textSize="24sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:text="@string/home_backend"
android:textColor="@color/neutral_400"
android:textSize="15sp" />
<LinearLayout
android:id="@+id/home_play"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_marginTop="22dp"
android:background="@drawable/bg_play_button"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:minWidth="190dp"
android:orientation="horizontal"
android:paddingStart="20dp"
android:paddingEnd="24dp">
<ImageView
android:id="@+id/home_play_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:duplicateParentState="true"
android:importantForAccessibility="no"
android:src="@drawable/ic_play"
android:tint="@color/play_button_content" />
<TextView
android:id="@+id/home_play_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:duplicateParentState="true"
android:fontFamily="sans-serif-medium"
android:text="@string/home_play"
android:textColor="@color/play_button_content"
android:textSize="17sp" />
</LinearLayout>
<TextView
android:id="@+id/home_secondary"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_button_secondary"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:minWidth="190dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textColor="@color/neutral_100"
android:textSize="15sp"
android:visibility="gone" />
<ProgressBar
android:id="@+id/home_progress"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="190dp"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:max="1000"
android:progressBackgroundTint="@color/neutral_700"
android:progressTint="@color/primary_400"
android:visibility="gone" />
<TextView
android:id="@+id/home_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:minLines="2"
android:textColor="@color/neutral_400"
android:textSize="13sp" />
<TextView
android:id="@+id/home_cancel"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_nav_item"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/home_cancel"
android:textColor="@color/neutral_200"
android:textSize="14sp"
android:visibility="gone" />
<Space
android:layout_width="match_parent"
android:layout_height="40dp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/launcher_nav_settings" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/settings_tabs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="-14dp"
android:orientation="horizontal" />
</HorizontalScrollView>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ScrollView
android:id="@+id/settings_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadeScrollbars="false"
android:scrollbarStyle="outsideOverlay">
<LinearLayout
android:id="@+id/settings_rows"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="14dp"
android:paddingBottom="28dp" />
</ScrollView>
</LinearLayout>
@@ -1,4 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1A237E</color>
<!-- The launcher uses WheelWizard's palette (Views/Styles/Resources/Constants.axaml)
so the Quest and PC launchers read as the same product. -->
<color name="neutral_50">#F6F7F9</color>
<color name="neutral_100">#EDEDF1</color>
<color name="neutral_200">#D7D9E0</color>
<color name="neutral_300">#B4B8C5</color>
<color name="neutral_400">#8B91A5</color>
<color name="neutral_500">#6C7389</color>
<color name="neutral_600">#575D72</color>
<color name="neutral_700">#474B5D</color>
<color name="neutral_800">#3E424E</color>
<color name="neutral_900">#363944</color>
<color name="neutral_950">#24252D</color>
<color name="primary_200">#8EFFE2</color>
<color name="primary_300">#34EAC5</color>
<color name="primary_400">#01CBA5</color>
<color name="primary_600">#00A488</color>
<color name="primary_700">#03826E</color>
<color name="primary_800">#08675A</color>
<color name="warning_500">#FFC800</color>
<color name="warning_600">#D19200</color>
<color name="danger_500">#FE2048</color>
</resources>
+168
View File
@@ -3,4 +3,172 @@
<string name="app_name">WiiCompiled VR</string>
<string name="missing_game_data_title">Game data not found</string>
<string name="missing_game_data_message">Copy the extracted DATA directory of your own PAL RMCP01 Mario Kart Wii disc to:\n\n%1$s\n\nFor example with adb: adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\n\nThen start the app again.</string>
<!-- Launcher shell -->
<string name="launcher_section_general">General</string>
<string name="launcher_nav_home">Home</string>
<string name="launcher_nav_settings">Settings</string>
<string name="launcher_version">v%1$s</string>
<!-- Home -->
<string name="home_title_base">Mario Kart Wii</string>
<string name="home_title_retro_rewind">Retro Rewind</string>
<string name="home_backend">WiiCompiled OpenXR VR</string>
<string name="home_play">Play</string>
<string name="home_resume">Resume</string>
<string name="home_select_disc">Select disc image</string>
<string name="home_running">The game is still running in the background.</string>
<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: the game is built from your own PAL (RMCP01) disc.\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.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, and press Select disc image.</string>
<string name="home_game_missing">The game files are ready. The game itself is built from your disc: in the WiiCompiled VR launcher on your computer, use Build for Quest, copy the .wcgame file to this headset, then press Import from computer.</string>
<string name="home_game_stale">This app was updated, so the game must be built again for it: use Build for Quest in the WiiCompiled VR launcher again, then Import from computer.</string>
<string name="home_checking_package">Checking game file…</string>
<string name="home_checking_package_status">Checking the game file was built from your disc for this version of the app.</string>
<string name="home_importing">Importing %1$d%%</string>
<string name="home_replace_game_title">Replace the game?</string>
<string name="home_replace_game_message">The game installed now stays until the new game file has been checked and completely imported.</string>
<string name="home_data_incomplete">DATA is there but incomplete or unreadable. Select your disc image to extract it again, or copy the whole extracted partition (sys/ and files/, not just files/) to:\n%1$s</string>
<string name="home_checking">Checking disc…</string>
<string name="home_extracting">Extracting %1$d%%</string>
<string name="home_finishing">Finishing…</string>
<string name="home_extract_progress">%1$s of %2$s</string>
<string name="home_checking_status">Reading the disc image and checking it is the clean PAL game.</string>
<string name="home_cancel">Cancel</string>
<string name="home_setup_done">The game files are ready.</string>
<string name="home_setup_cancelled">Extraction cancelled.</string>
<string name="home_setup_failed">The game files could not be set up.\n%1$s</string>
<string name="home_no_picker">This headset has no file picker to choose a disc image with.</string>
<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>
<!-- Settings shell -->
<string name="settings_tab_vr">VR</string>
<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_about">About</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>
<string name="settings_custom_value">Custom (%1$s)</string>
<!-- VR tab -->
<string name="section_vr_camera">Camera</string>
<string name="vr_camera">Camera</string>
<string name="vr_camera_helper">Ride behind the kart like the game, or sit in the driver\'s seat.</string>
<string name="vr_camera_chase">Chase camera</string>
<string name="vr_camera_first_person">First person</string>
<string name="vr_rotation">Follow kart rotation</string>
<string name="vr_rotation_helper">In first person, follow turning only (the comfortable choice), turning and climbing, or all kart rotation including banking.</string>
<string name="vr_rotation_yaw">Turning only</string>
<string name="vr_rotation_yaw_pitch">Turning + pitch</string>
<string name="vr_rotation_full">Full rotation</string>
<string name="vr_hide">Hide in first person</string>
<string name="vr_hide_helper">Your driver sits where your eyes are. Other racers are never hidden.</string>
<string name="vr_hide_nothing">Nothing</string>
<string name="vr_hide_driver">Driver</string>
<string name="vr_hide_driver_and_kart">Driver and kart</string>
<string name="vr_lean_back">Lean back angle</string>
<string name="vr_lean_back_helper">Tilts the race view back for playing reclined. 0 applies no tilt.</string>
<string name="section_vr_headset">Headset</string>
<string name="vr_render_scale">Headset render scale</string>
<string name="vr_render_scale_helper">Scales the eye resolution the headset recommends. Higher is sharper and costs GPU time; lower it if races stutter.</string>
<string name="vr_interpolation">VR frame interpolation</string>
<string name="vr_interpolation_helper">Experimental. Renders extra frames between game frames at the headset\'s rate. Needs GPU headroom and adds one game frame of latency.</string>
<string name="vr_interpolation_off">Off</string>
<string name="vr_interpolation_auto">Auto</string>
<string name="section_vr_screen">Virtual screen</string>
<string name="vr_hud_screen">Race HUD on the virtual screen</string>
<string name="vr_hud_screen_helper">Puts the minimap, position and item roulette on a screen in front of you instead of stretching them across the view.</string>
<string name="vr_hud_distance">Screen distance</string>
<string name="vr_hud_distance_helper">How far the menu screen and race HUD sit in front of you.</string>
<string name="vr_hud_width">Screen width</string>
<string name="vr_hud_width_helper">How wide the menu screen and race HUD are.</string>
<!-- Graphics tab -->
<string name="section_graphics">Rendering</string>
<string name="graphics_resolution">Internal resolution</string>
<string name="graphics_resolution_helper">The game\'s own render size. Native keeps the headset at its best frame rate.</string>
<string name="graphics_resolution_native">Native (1x)</string>
<string name="graphics_widescreen">Widescreen</string>
<string name="graphics_widescreen_helper">16:9 menus and races instead of 4:3.</string>
<string name="graphics_bloom">Disable bloom</string>
<string name="graphics_bloom_helper">Bloom\'s bright glow reads poorly in a headset, so it starts off.</string>
<string name="graphics_skip_unready">Prevent shader stutters</string>
<string name="graphics_skip_unready_helper">Skips a draw for a moment while its shader compiles instead of pausing the game.</string>
<!-- Controls tab -->
<string name="section_controls">Controllers</string>
<string name="controls_mode">VR controllers</string>
<string name="controls_mode_helper">What the Touch controllers are to the game.</string>
<string name="controls_mode_wii_remote">Wii Remote + Nunchuk</string>
<string name="controls_mode_gamepad">Gamepad</string>
<string name="controls_rumble">Vibration</string>
<string name="controls_rumble_helper">The game\'s rumble vibrates the controllers.</string>
<string name="section_controls_mapping">Wii Remote + Nunchuk buttons</string>
<string name="controls_map_a">A</string>
<string name="controls_map_a_value">Right A</string>
<string name="controls_map_b">B</string>
<string name="controls_map_b_value">Right trigger</string>
<string name="controls_map_12">1 / 2</string>
<string name="controls_map_12_value">Right stick up / down</string>
<string name="controls_map_minus_plus">− / +</string>
<string name="controls_map_minus_plus_value">Right stick left / right</string>
<string name="controls_map_stick">Nunchuk stick</string>
<string name="controls_map_stick_value">Left stick</string>
<string name="controls_map_z">Z</string>
<string name="controls_map_z_value">Left trigger</string>
<string name="controls_map_c">C</string>
<string name="controls_map_c_value">Left grip</string>
<string name="controls_map_home">HOME</string>
<string name="controls_map_home_value">Left menu</string>
<string name="controls_map_pointer">Pointer and motion</string>
<string name="controls_map_pointer_value">Aim and move the right controller</string>
<string name="controls_map_panel">Settings panel</string>
<string name="controls_map_panel_value">Click both thumbsticks</string>
<!-- Audio tab -->
<string name="section_audio">Volume</string>
<string name="audio_master">Master</string>
<string name="audio_music">Music</string>
<string name="audio_sound_effects">Sound effects</string>
<string name="audio_voices">Voices</string>
<string name="audio_ui">Menu sounds</string>
<string name="audio_muted">Mute</string>
<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="about_version_value">%1$s (%2$s)</string>
<string name="section_about_storage">Files</string>
<string name="about_game_data">Game data</string>
<string name="about_game_data_ready">Found: %1$s</string>
<string name="about_game_data_incomplete">Incomplete or unreadable: %1$s</string>
<string name="about_game_data_missing">Missing: %1$s</string>
<string name="about_config">Configuration</string>
<string name="about_logs">Logs</string>
<string name="about_extract">Extract from disc image</string>
<string name="about_extract_helper">Replaces DATA with the files of your own PAL disc image.</string>
<string name="about_game">Game</string>
<string name="about_game_ready">Built by %1$s on %2$s</string>
<string name="about_game_missing">Not installed yet</string>
<string name="about_game_stale">Built for another version of this app; build it again</string>
<string name="about_import_helper">Installs a .wcgame made by Build for Quest in the WiiCompiled VR launcher on a computer.</string>
<string name="section_about_diagnostics">Diagnostics</string>
<string name="about_openxr_logging">OpenXR diagnostic logging</string>
<string name="about_openxr_logging_helper">Writes VR frame timing to the run log once per second: late, skipped and black frames, tracking loss. Turn it on to report stutter, and off again afterwards.</string>
<!-- Disc setup notification -->
<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_extracting">Extracting: %1$d%%</string>
<string name="disc_setup_importing">Importing: %1$d%%</string>
<string name="game_not_installed">The game is not installed. Open WiiCompiled VR and set it up from Home.</string>
</resources>
@@ -6,4 +6,81 @@
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- The 2D launcher panel: dark Material widgets tinted with the launcher palette. -->
<style name="Theme.WiiCompiled.Launcher" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/neutral_900</item>
<item name="android:statusBarColor">@color/neutral_900</item>
<item name="android:navigationBarColor">@color/neutral_900</item>
<item name="android:colorPrimary">@color/neutral_900</item>
<item name="android:colorAccent">@color/primary_400</item>
<item name="android:colorControlNormal">@color/neutral_400</item>
<item name="android:textColorPrimary">@color/neutral_100</item>
<item name="android:textColorSecondary">@color/neutral_400</item>
<item name="android:colorBackground">@color/neutral_950</item>
<item name="android:alertDialogTheme">@style/Theme.WiiCompiled.Launcher.Dialog</item>
</style>
<style name="Theme.WiiCompiled.Launcher.Dialog" parent="android:Theme.Material.Dialog.Alert">
<item name="android:colorAccent">@color/primary_400</item>
<item name="android:colorBackground">@color/neutral_900</item>
</style>
<!-- Namespace for the launcher's view styles below. -->
<style name="Launcher" />
<style name="Launcher.PageTitle">
<item name="android:textColor">@color/neutral_300</item>
<item name="android:textSize">26sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
<style name="Launcher.SectionLabel">
<item name="android:textColor">@color/neutral_300</item>
<item name="android:textSize">14sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
<style name="Launcher.NavItem">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">48dp</item>
<item name="android:orientation">horizontal</item>
<item name="android:gravity">center_vertical</item>
<item name="android:paddingStart">12dp</item>
<item name="android:paddingEnd">12dp</item>
<item name="android:layout_marginBottom">2dp</item>
<item name="android:background">@drawable/bg_nav_item</item>
<item name="android:clickable">true</item>
<item name="android:focusable">true</item>
</style>
<style name="Launcher.NavIcon">
<item name="android:layout_width">24dp</item>
<item name="android:layout_height">24dp</item>
<item name="android:tint">@color/nav_content</item>
<item name="android:duplicateParentState">true</item>
</style>
<style name="Launcher.NavLabel">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginStart">14dp</item>
<item name="android:textColor">@color/nav_content</item>
<item name="android:textSize">19sp</item>
<item name="android:duplicateParentState">true</item>
</style>
<style name="Launcher.Tab">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">44dp</item>
<item name="android:gravity">center</item>
<item name="android:paddingStart">14dp</item>
<item name="android:paddingEnd">14dp</item>
<item name="android:textSize">15sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
<item name="android:textColor">@color/tab_text</item>
<item name="android:background">@drawable/bg_tab</item>
<item name="android:clickable">true</item>
<item name="android:focusable">true</item>
</style>
</resources>
@@ -0,0 +1,55 @@
package org.wiicompiled.quest.launcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/** The Quest launcher must accept and reject disc images exactly as the PC installer does. */
class DiscChecksTest {
private val dol = "clean dol".toByteArray()
private val rel = "clean rel".toByteArray()
private val checks = DiscChecks("RMCP01", DiscChecks.sha256(dol), DiscChecks.sha256(rel))
@Test
fun acceptsTheFormatsNodReads() {
for (name in listOf("mkw.iso", "MKW.ISO", "game.rvz", "game.wbfs", "a.b.wia", "x.ciso", "x.gcz", "x.gcm")) {
assertNull(name, checks.extensionError(name))
}
for (name in listOf("mkw.zip", "DATA", "mkw.iso.part", null)) {
assertNotNull(name, checks.extensionError(name))
}
}
@Test
fun parsesTheNativeHeader() {
val header = DiscChecks.parseHeader("RMCP01\nMARIO KART Wii\n2891234567")
assertEquals(DiscChecks.Header("RMCP01", "MARIO KART Wii", 2_891_234_567L), header)
assertEquals(0L, DiscChecks.parseHeader("RMCP01").extractedBytes)
}
@Test
fun rejectsOtherRegionsWithTheInstallerMessage() {
assertNull(checks.compatibilityError(DiscChecks.Header("rmcp01", "MARIO KART Wii", 1)))
assertEquals(
"This build supports Mario Kart Wii PAL (RMCP01). The selected image is RMCE01 (MARIO KART Wii, NTSC-U).",
checks.compatibilityError(DiscChecks.Header("RMCE01", "MARIO KART Wii", 1)),
)
}
@Test
fun requiresTheCleanRevision() {
assertNull(checks.revisionError(dol, rel))
assertNotNull(checks.revisionError("patched".toByteArray(), rel))
assertNotNull(checks.revisionError(dol, "patched".toByteArray()))
}
@Test
fun hashesAsLowercaseHex() {
assertEquals(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
DiscChecks.sha256("abc".toByteArray()),
)
}
}
@@ -0,0 +1,127 @@
package org.wiicompiled.quest.launcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The launcher and the runtime edit the same Config.toml, so the line editor
* must place keys exactly where RuntimeConfigFile::WriteSetting does and read
* only what the runtime's toml11 lookups accept.
*/
class TomlConfigTest {
private val sample = """
# WiiCompiled Quest configuration.
[paths]
dvd_root = "/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA"
[video]
widescreen = true # set by the activity
resolution_multiplier = 1.0
[vr]
enabled = true
render_scale = 1.0
""".trimIndent() + "\n"
@Test
fun untouchedFileRoundTrips() {
assertEquals(sample, TomlConfig.parse(sample).text())
val crlf = sample.replace("\n", "\r\n")
assertEquals(sample, TomlConfig.parse(crlf).text())
}
@Test
fun replacesAKeyInPlace() {
val config = TomlConfig.parse(sample)
config.setFloat("vr", "render_scale", 0.75)
assertEquals(sample.replace("render_scale = 1.0", "render_scale = 0.75"), config.text())
}
@Test
fun insertsAMissingKeyAboveTheBlankLineBeforeTheNextSection() {
val config = TomlConfig.parse(sample)
config.setBool("video", "skip_unready_pipelines", false)
val expected = sample.replace(
"resolution_multiplier = 1.0\n\n[vr]",
"resolution_multiplier = 1.0\nskip_unready_pipelines = false\n\n[vr]",
)
assertEquals(expected, config.text())
}
@Test
fun appendsAMissingSection() {
val config = TomlConfig.parse(sample)
config.setFloat("audio", "volume", 0.5)
assertEquals("$sample\n[audio]\nvolume = 0.5\n", config.text())
}
@Test
fun createsAFileFromNothing() {
val config = TomlConfig.parse("")
config.setString("vr", "controller_mode", "gamepad")
assertEquals("[vr]\ncontroller_mode = \"gamepad\"\n", config.text())
}
@Test
fun findsKeysOnlyInTheirOwnSection() {
val config = TomlConfig.parse("[video]\nenabled = false\n\n[vr]\nenabled = true\n")
assertEquals(false, config.bool("video", "enabled"))
assertEquals(true, config.bool("vr", "enabled"))
assertNull(config.bool("audio", "enabled"))
}
@Test
fun ignoresCommentsButNotHashesInsideStrings() {
val config = TomlConfig.parse(
"[paths] # headers may carry comments\n" +
"dvd_root = \"/data/#1/DATA\" # a comment\n" +
"# enabled = true\n",
)
assertEquals("/data/#1/DATA", config.string("paths", "dvd_root"))
assertNull(config.literal("paths", "enabled"))
}
@Test
fun readsNumbersTheWayTheRuntimeDoes() {
val config = TomlConfig.parse(
"[vr]\n" +
"render_scale = 1\n" +
"hud_width_meters = 2.4\n" +
"lean_back_degrees = -1_0.5\n" +
"frame_interpolation_fps = 72\n" +
"first_person_hidden_model = 72.0\n" +
"world_units_per_meter = 1.0f\n" +
"hud_distance_meters = \"2.0\"\n",
)
// FindConfigFloat accepts integers as well as floats.
assertEquals(1.0, config.number("vr", "render_scale")!!, 0.0)
assertEquals(2.4, config.number("vr", "hud_width_meters")!!, 0.0)
assertEquals(-10.5, config.number("vr", "lean_back_degrees")!!, 0.0)
// Integer keys reject floats; anything that is not a TOML number is absent.
assertEquals(72L, config.integer("vr", "frame_interpolation_fps"))
assertNull(config.integer("vr", "first_person_hidden_model"))
assertNull(config.number("vr", "world_units_per_meter"))
assertNull(config.number("vr", "hud_distance_meters"))
}
@Test
fun formatsFloatsSoTomlReadsAFloat() {
assertEquals("1.0", TomlConfig.formatFloat(1.0))
assertEquals("0.05", TomlConfig.formatFloat(0.05))
assertEquals("1.0", TomlConfig.formatFloat(0.25 + 15 * 0.05))
assertEquals("-12.0", TomlConfig.formatFloat(-12.0))
assertEquals("0.0", TomlConfig.formatFloat(-0.0))
}
@Test
fun quotesAndUnquotesStrings() {
val value = "C:\\Games\\\"Kart\"\ttab"
assertEquals(value, TomlConfig.unquote(TomlConfig.quote(value)))
assertEquals("yaw", TomlConfig.unquote("'yaw'"))
assertEquals("é", TomlConfig.unquote("\"\\u00e9\""))
assertNull(TomlConfig.unquote("yaw"))
assertNull(TomlConfig.unquote("\"bad\\q\""))
}
}