Merge remote-tracking branch 'upstream/openxr-work' into codex/standalone-selective-integration

# Conflicts:
#	docs/quest-port.md
This commit is contained in:
darthcircuit committed 2026-09-23 11:28:20 -06:00
commit 0e52cfb726
141 files changed
+14235 -1090

No files matched your search

+3
View File
@@ -26,6 +26,9 @@
<uses-feature android:name="android.hardware.vulkan.version" android:required="true" android:version="0x00401000" />
<uses-feature android:name="android.hardware.vr.headtracking" android:required="true" android:version="1" />
<uses-feature android:name="android.software.xr.api.openxr" android:required="true" />
<!-- XR_FB_passthrough around the menu screen: without this flag Horizon OS keeps the app
passthrough-disabled and the passthrough layer composites nothing. No camera permission. -->
<uses-feature android:name="com.oculus.feature.PASSTHROUGH" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
@@ -33,6 +33,9 @@ object GameStorage {
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY)
/** Mods imported on the Patches page, one folder each, as WheelWizard keeps them on a computer. */
const val MODS_DIRECTORY = "Mods"
/** The pack's own Code.pul, which is what a modded game and a mod translation both need. */
fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul")
@@ -40,6 +43,11 @@ object GameStorage {
fun modContentReady(context: Context, profile: GameProfile): Boolean =
!profile.modPack || modCodePul(context).isFile
fun modsDirectory(context: Context): File = File(gameRoot(context), MODS_DIRECTORY)
/** The pack's Patches folder, which its Riivolution XML maps onto the disc; refilled from Mods at each start. */
fun patchesDirectory(context: Context): File = File(modDirectory(context), "Patches")
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
@@ -78,7 +86,7 @@ object GameStorage {
// Written line by line: trimIndent runs after interpolation, so an interpolated line
// would take the indent off every other one.
val lines = mutableListOf(
"# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.",
"# WiiCompiled Quest configuration. Edit with the launcher or the in-game panel. After an adb push, run chmod 664 on it or the app can no longer save settings.",
"[paths]",
"dvd_root = \"${discDirectory(context).absolutePath}\"",
)
@@ -35,7 +35,8 @@ import org.wiicompiled.quest.GameStorage
* game kit (assets/game_kit) into private storage;
* 2. download the Android NDK files the build needs from Google, checked against the pins the
* toolchain carries (ndk.json);
* 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards;
* 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards
* (Retro Rewind adds translate-mod, with the Retro-WFC payload downloaded from rwfc.net);
* 4. compile the generated sources with the kit's flags, several at a time;
* 5. link them with the kit's objects and archives (kit.json link.lld);
* 6. install libmain.so and its game.json like an imported game.
@@ -65,6 +66,12 @@ object GameBuild {
private const val TRANSLATOR_THREADS = 4
private const val EXPECTED_TRANSLATION_SECONDS = 600.0
private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024
// WiiCompiled Setup's fixed endpoint, size cap and staging layout
// (Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs), which validate-retro-wfc-payload expects.
private const val RETRO_WFC_PAYLOAD_URL = "https://rwfc.net/api/wfc/payload?g=RMCPD00"
private const val RETRO_WFC_PAYLOAD_MAX_BYTES = 16 * 1024 * 1024
private const val RETRO_WFC_DIRECTORY = "retro-wfc"
private const val RETRO_WFC_PAYLOAD_FILE = "binary/payload.RMCPD00.bin"
/** Builds [profile]'s game. Null on success, otherwise the message to show. */
fun run(context: Context, profile: GameProfile, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
@@ -279,13 +286,82 @@ object GameBuild {
}
}
/**
* The Retro-WFC payload Retro Rewind's online play runs. translate-mod lowers it into the
* mod; without it the mod downloads the payload while connecting and jumps into code that
* was never translated. Retried once, like Setup's download.
*/
fun downloadRetroWfcPayload(): File {
val file = File(workspace, "$RETRO_WFC_DIRECTORY/$RETRO_WFC_PAYLOAD_FILE")
file.parentFile?.mkdirs()
log.line("Downloading the Retro-WFC payload from $RETRO_WFC_PAYLOAD_URL")
var failure: IOException? = null
for (attempt in 1..2) {
if (cancelled()) throw InterruptedIOException("Build cancelled")
try {
file.writeBytes(fetchRetroWfcPayload())
log.line("Retro-WFC payload: ${file.length()} bytes, sha256 ${BuildRecipe.hex(sha256(file))}")
return file
} catch (e: IOException) {
// A socket timeout is an InterruptedIOException too, which run() takes for a cancel.
failure = e
log.line("Retro-WFC payload download attempt $attempt failed: $e")
if (attempt == 1) Thread.sleep(1_000)
}
}
if (cancelled()) throw InterruptedIOException("Build cancelled")
throw IOException(
"Retro Rewind's online play needs the Retro-WFC payload from rwfc.net, which could not be " +
"downloaded (${failure?.message}). Check the headset's internet connection, then build again.",
)
}
fun fetchRetroWfcPayload(): ByteArray {
val connection = URL(RETRO_WFC_PAYLOAD_URL).openConnection() as HttpURLConnection
try {
connection.connectTimeout = 30_000
connection.readTimeout = 30_000
// A redirect would fetch from a target other than the fixed endpoint; Setup refuses it too.
connection.instanceFollowRedirects = false
connection.setRequestProperty("Accept-Encoding", "identity")
val code = connection.responseCode
if (code != HttpURLConnection.HTTP_OK) throw IOException("rwfc.net answered $code")
connection.inputStream.use { input ->
val bytes = java.io.ByteArrayOutputStream()
val buffer = ByteArray(64 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
bytes.write(buffer, 0, read)
if (bytes.size() > RETRO_WFC_PAYLOAD_MAX_BYTES) throw IOException("the payload is unexpectedly large")
}
return bytes.toByteArray()
}
} finally {
connection.disconnect()
}
}
/** Translates the disc unless this workspace already holds a translation of the same inputs. */
fun translate(tools: ToolProcess, identity: String): String? {
val generated = File(workspace, "generated")
val provenance = File(generated, "translation-provenance.txt")
// Fetched before anything else, so an unreachable server fails the build before the
// long base translation rather than after it.
val payload = if (profile.modPack) downloadRetroWfcPayload() else null
if (payload != null) {
translator(
tools, "checking the Retro-WFC payload",
"validate-retro-wfc-payload", "--directory", File(workspace, RETRO_WFC_DIRECTORY).absolutePath,
)?.let { return it }
}
// A modded game needs a base translation that knows this Code.pul, so the pack's own
// identity is part of what the stored translation is reused for.
val modIdentity = if (profile.modPack) BuildRecipe.hex(sha256(GameStorage.modCodePul(context))) else ""
// identity (and the payload's) is part of what the stored translation is reused for.
val modIdentity = if (payload != null) {
"${BuildRecipe.hex(sha256(GameStorage.modCodePul(context)))} ${BuildRecipe.hex(sha256(payload))}"
} else {
""
}
val expected = "$identity ${profile.id} ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256} $modIdentity"
val shards = File(generated, "build_shards/shards.cmake")
if (provenance.isFile && provenance.readText() == expected && shards.isFile) {
@@ -327,7 +403,7 @@ object GameBuild {
}?.let { return it }
// Retro Rewind's own code: its Code.pul translated against the base translation, as
// Launcher/LocalBuild.ps1 does on a PC. Online play needs a payload this cannot fetch.
// Launcher/LocalBuild.ps1 does on a PC, with the Retro-WFC payload for online play.
val modOutput = "build/mods/retro_rewind_full_cpp"
if (profile.modPack) {
report(350, Step.Translate, 2, steps)
@@ -345,7 +421,7 @@ object GameBuild {
"--code-pul", GameStorage.modCodePul(context).absolutePath,
"--mod-root", GameStorage.modDirectory(context).absolutePath,
"--mod-name", "Retro Rewind", "--region", "P", "--out", modOutput,
"--prefer-cached-inputs", "--emit-cpp", "--skip-retro-wfc",
"--prefer-cached-inputs", "--emit-cpp", "--retro-wfc-payload", payload!!.absolutePath,
"--threads", TRANSLATOR_THREADS.toString(),
)?.let { return it }
}
@@ -28,7 +28,8 @@ 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.
* with a Home page that sets up and starts the game, a Patches page that imports mods for Retro
* Rewind, 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
@@ -41,14 +42,16 @@ import org.wiicompiled.quest.R
*/
class LauncherActivity : Activity() {
private enum class Page { Home, Settings }
private enum class Page { Home, Patches, Settings }
/** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
private lateinit var navHome: View
private lateinit var navPatches: View
private lateinit var navSettings: View
private lateinit var homePage: View
private lateinit var patchesView: View
private lateinit var settingsView: View
private lateinit var trails: WheelTrailsView
private lateinit var playButton: View
@@ -64,6 +67,7 @@ class LauncherActivity : Activity() {
private lateinit var homeTitle: TextView
private lateinit var gameToggle: LinearLayout
private lateinit var settings: SettingsPage
private lateinit var patches: PatchesPage
/** The games this APK carries a kit for, and the one the player picked. */
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
@@ -71,6 +75,8 @@ class LauncherActivity : Activity() {
private var page = Page.Home
private var launching = false
/** Play is copying the enabled mods into Retro Rewind's Patches folder before starting it. */
private var installingPatches = false
private var trailsAway = true
private var setupKind: Class<*>? = null
private var mainAction = Action.Play
@@ -95,8 +101,10 @@ class LauncherActivity : Activity() {
}
navHome = findViewById(R.id.nav_home)
navPatches = findViewById(R.id.nav_patches)
navSettings = findViewById(R.id.nav_settings)
homePage = findViewById(R.id.page_home)
patchesView = findViewById(R.id.page_patches)
settingsView = findViewById(R.id.page_settings)
trails = findViewById(R.id.home_trails)
playButton = findViewById(R.id.home_play)
@@ -129,11 +137,15 @@ class LauncherActivity : Activity() {
downloadModPack = ::downloadModPack,
resetInstallation = ::resetInstallation,
)
patches = PatchesPage(this, patchesView) {
openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker)
}
savedInstanceState?.getString(KEY_TAB)?.let { name ->
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
}
navHome.setOnClickListener { showPage(Page.Home) }
navPatches.setOnClickListener { showPage(Page.Patches) }
navSettings.setOnClickListener { showPage(Page.Settings) }
playButton.setOnClickListener { perform(mainAction) }
secondary.setOnClickListener { secondaryAction?.let(::perform) }
@@ -182,8 +194,16 @@ class LauncherActivity : Activity() {
@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
if (resultCode != RESULT_OK || data == null) return
if (requestCode == REQUEST_PATCH_FILES) {
// Several files arrive as clip data, a single one as the data URI.
val clip = data.clipData
val uris = if (clip != null) (0 until clip.itemCount).map { clip.getItemAt(it).uri } else listOfNotNull(data.data)
showPage(Page.Patches)
patches.importPicked(uris)
return
}
val uri = data.data ?: return
val task = when (requestCode) {
REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc
REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage
@@ -197,8 +217,10 @@ class LauncherActivity : Activity() {
private fun showPage(target: Page) {
page = target
navHome.isSelected = target == Page.Home
navPatches.isSelected = target == Page.Patches
navSettings.isSelected = target == Page.Settings
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
if (target == Page.Home) {
trailsAway = true
@@ -209,6 +231,7 @@ class LauncherActivity : Activity() {
private fun refresh() {
when (page) {
Page.Home -> refreshHome()
Page.Patches -> patches.refresh()
Page.Settings -> settings.refresh()
}
}
@@ -278,10 +301,14 @@ class LauncherActivity : Activity() {
setup is GameSetup.State.Finishing -> getString(R.string.home_finishing)
else -> getString(label(mainAction))
}
if (installingPatches) {
playButton.isEnabled = false
playText.setText(R.string.home_installing_patches)
}
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.visibility = if (settingUp || installingPatches) 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()
@@ -335,9 +362,10 @@ class LauncherActivity : Activity() {
showBanner(getString(R.string.home_mod_needed_message), warning = true)
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
showBanner(getString(R.string.home_setup_intro), warning = false)
showBanner(getString(R.string.home_setup_intro) + discMd5Note(), 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)
discStatus == GameStorage.DiscStatus.Missing ->
showBanner(getString(R.string.home_data_missing, disc) + discMd5Note(), warning = false)
else -> showBanner(null)
}
@@ -500,6 +528,9 @@ class LauncherActivity : Activity() {
}
}
/** The clean disc's .iso hash, for the banners that ask for a disc image. */
private fun discMd5Note(): String = "\n\n" + getString(R.string.disc_md5_note, getString(R.string.disc_md5))
private fun showBanner(text: String?, warning: Boolean = false) {
if (text == null) {
dataBanner.visibility = View.GONE
@@ -549,18 +580,19 @@ class LauncherActivity : Activity() {
.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.
private fun openPicker(requestCode: Int, multiple: Boolean = false, noPicker: Int = R.string.home_no_picker) {
// Disc images, game files and mod 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("*/*")
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple)
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()
Toast.makeText(this, noPicker, Toast.LENGTH_LONG).show()
}
}
@@ -589,6 +621,66 @@ class LauncherActivity : Activity() {
Toast.makeText(this, R.string.home_quest1_launch_from_library, Toast.LENGTH_LONG).show()
return
}
if (launching) {
return
}
// Resume only brings the running game back, and its files must not change under it.
if (profile.modPack && !isGameRunning()) {
preparePatches()
return
}
startGame()
}
/**
* Retro Rewind reads its pack's Patches folder, so the enabled mods are copied into it before
* every start, as the PC launcher does (ModsLaunchService.PrepareModsForLaunch). With none
* enabled, a folder that still holds files is only cleared if the player says so.
*/
private fun preparePatches() {
val mods = ModLibrary.load(GameStorage.modsDirectory(this))
when {
ModLibrary.shouldAskToClear(mods, GameStorage.patchesDirectory(this)) ->
AlertDialog.Builder(this)
.setTitle(R.string.patches_clear_title)
.setMessage(R.string.patches_clear_message)
.setPositiveButton(R.string.patches_delete) { _, _ -> installPatches(mods, clear = true) }
.setNegativeButton(R.string.patches_keep) { _, _ -> startGame() }
.show()
mods.any { it.enabled } -> installPatches(mods, clear = false)
else -> startGame()
}
}
private fun installPatches(mods: List<ModLibrary.Mod>, clear: Boolean) {
if (launching) return
launching = true
installingPatches = true
refreshHome()
val modsDir = GameStorage.modsDirectory(this)
val patchesDir = GameStorage.patchesDirectory(this)
ModLibrary.background({ ModLibrary.prepareForLaunch(modsDir, patchesDir, mods, clear) }) { result ->
installingPatches = false
launching = false
if (isDestroyed) return@background
val error = result.getOrElse { it.message ?: it.toString() }
if (error != null) {
Log.w(TAG, "Mods could not be installed: $error")
refreshHome()
AlertDialog.Builder(this)
.setTitle(R.string.home_patches_failed)
.setMessage(error)
.setPositiveButton(android.R.string.ok, null)
.show()
return@background
}
Log.i(TAG, "Patches folder ready: ${mods.count { it.enabled }} of ${mods.size} mods enabled")
refreshHome()
startGame()
}
}
private fun startGame() {
if (launching) {
return
}
@@ -634,6 +726,7 @@ class LauncherActivity : Activity() {
const val PROCESS_EXIT_GRACE_MS = 1000L
const val REQUEST_DISC_IMAGE = 1
const val REQUEST_GAME_PACKAGE = 2
const val REQUEST_PATCH_FILES = 3
const val PREFERENCES = "launcher"
const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport"
const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME"
@@ -0,0 +1,341 @@
package org.wiicompiled.quest.launcher
import android.os.Handler
import android.os.Looper
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.util.Locale
import java.util.concurrent.Executors
import java.util.zip.ZipFile
/**
* The mods imported on the Patches page, kept the way WheelWizard VR keeps them on a computer
* (Features/Mods): one folder per mod under Mods/, holding the mod's files and a `<name>.ini` with
* its state, so a Mods folder copied from one launcher reads the same in the other.
*
* Mods only change Retro Rewind. Before it starts, [plan] and [sync] flatten the enabled mods into
* the pack's Patches folder, which the pack's Riivolution XML maps onto the disc (/patches, /sound),
* as the PC launcher's ModsLaunchService does before every Retro Rewind launch. A file two mods
* both carry comes from the one higher in the list, which is the one with the lower priority.
*/
object ModLibrary {
/** One imported mod, as its `.ini` describes it. Author and ModID only come from the PC's mod browser. */
data class Mod(
val title: String,
val enabled: Boolean,
val priority: Int,
val author: String = NO_ID,
val modId: Int = -1,
)
/** One file picked for an import: its display name and how to read it. */
class Source(val name: String, val open: () -> InputStream)
enum class NameProblem { Empty, Exists, IllegalCharacters }
private const val SECTION = "Mod"
private const val NO_ID = "-1"
/** ModManager._illegalChars plus Windows' invalid file name characters, so a name travels to the PC. */
private val ILLEGAL_NAME_CHARACTERS = ".~/\\<>:\"|?*".toSet()
/** Archives the PC unpacks with SharpCompress; without it, only .zip can be opened here. */
private val UNSUPPORTED_ARCHIVES = listOf(".7z", ".rar")
// Metadata
/** Every mod under [modsDir] (`<name>/<name>.ini`), in list order: by priority, top first. */
fun load(modsDir: File): List<Mod> =
modsDir.listFiles { file -> file.isDirectory }
.orEmpty()
.mapNotNull { folder ->
val ini = File(folder, "${folder.name}.ini")
if (!ini.isFile) return@mapNotNull null
runCatching { parseIni(ini.readText()) }.getOrNull()
}
.sortedWith(compareBy<Mod> { it.priority }.thenBy { it.title.lowercase(Locale.ROOT) })
/**
* Reads what Mod.LoadFromIniAsync reads, with its defaults: enabled unless it says otherwise,
* priority 0 and ModID -1 when absent. Null without a name, which the PC skips as well.
*/
fun parseIni(text: String): Mod? {
val values = mutableMapOf<String, String>()
var section = ""
for (raw in text.removePrefix("").lineSequence()) {
val line = raw.trim()
when {
line.isEmpty() || line.startsWith(";") || line.startsWith("#") -> Unit
line.startsWith("[") && line.endsWith("]") -> section = line.substring(1, line.length - 1).trim()
section.equals(SECTION, ignoreCase = true) && '=' in line ->
values[line.substringBefore('=').trim().lowercase(Locale.ROOT)] = line.substringAfter('=').trim()
}
}
val title = values["name"]?.takeIf { it.isNotBlank() } ?: return null
return Mod(
title = title,
// bool.TryParse: either word in any case, and anything else keeps the default.
enabled = when (values["isenabled"]?.lowercase(Locale.ROOT)) {
"false" -> false
else -> true
},
priority = values["priority"]?.toIntOrNull() ?: 0,
author = values["author"] ?: NO_ID,
modId = values["modid"]?.toIntOrNull() ?: -1,
)
}
/** What Mod.SaveToIniAsync writes: the same keys, with .NET's True/False. */
fun iniText(mod: Mod): String = buildString {
append("[").append(SECTION).append("]\n")
append("Name = ").append(mod.title).append('\n')
append("Author = ").append(mod.author).append('\n')
append("ModID = ").append(mod.modId).append('\n')
append("IsEnabled = ").append(if (mod.enabled) "True" else "False").append('\n')
append("Priority = ").append(mod.priority).append('\n')
}
fun save(modsDir: File, mod: Mod) {
val folder = folder(modsDir, mod)
if (!folder.isDirectory) throw IOException("The folder of ${mod.title} is missing: ${folder.absolutePath}")
val ini = File(folder, "${mod.title}.ini")
val temporary = File(folder, "${mod.title}.ini.tmp")
temporary.writeText(iniText(mod))
if (!temporary.renameTo(ini)) {
temporary.delete()
ini.writeText(iniText(mod))
}
}
fun folder(modsDir: File, mod: Mod): File = File(modsDir, mod.title)
/** ModManager.ValidateModName: a new name must be non-empty, unused (any case) and a valid folder name. */
fun validateName(name: String, mods: List<Mod>): NameProblem? {
val trimmed = name.trim()
return when {
trimmed.isEmpty() -> NameProblem.Empty
mods.any { it.title.equals(trimmed, ignoreCase = true) } -> NameProblem.Exists
trimmed.any { it in ILLEGAL_NAME_CHARACTERS || it.code < 32 } -> NameProblem.IllegalCharacters
else -> null
}
}
/** A starting name for the import dialog: the file's name without its extensions, made valid. */
fun suggestName(fileName: String, mods: List<Mod>): String {
val base = fileName.substringAfterLast('/').substringBefore('.').trim()
.map { if (it in ILLEGAL_NAME_CHARACTERS || it.code < 32) ' ' else it }
.joinToString("").trim()
return base.takeIf { validateName(it, mods) == null } ?: ""
}
// Changes
/**
* ModManager.ImportModFilesAsync: the picked files become one new mod, enabled, below every
* existing one. A picked .zip is unpacked into it, as the PC does with a mod it downloads.
* The files are gathered beside the Mods folder's other entries and only moved into place once
* all are in, so a failed import leaves nothing behind.
*/
fun import(modsDir: File, title: String, sources: List<Source>, mods: List<Mod>): Mod {
val name = title.trim()
validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") }
if (sources.isEmpty()) throw IOException("No files were chosen.")
sources.firstOrNull { source -> UNSUPPORTED_ARCHIVES.any { source.name.endsWith(it, ignoreCase = true) } }?.let {
throw IOException("${it.name} is an archive this headset cannot open. Unpack it on a computer and import its files, or import a .zip.")
}
modsDir.mkdirs()
val staging = File(modsDir, ".$name.importing")
staging.deleteRecursively()
if (!staging.mkdirs()) throw IOException("Could not create ${staging.absolutePath}")
try {
for (source in sources) {
val fileName = source.name.substringAfterLast('/').substringAfterLast('\\')
if (fileName.isBlank() || fileName == "." || fileName == "..") throw IOException("A chosen file has no usable name.")
if (fileName.endsWith(".zip", ignoreCase = true)) {
unpack(source, staging)
} else {
source.open().use { input -> File(staging, fileName).outputStream().use { input.copyTo(it) } }
}
}
if (staging.walkTopDown().none { it.isFile }) throw IOException("There was nothing to import.")
val mod = Mod(name, enabled = true, priority = (mods.maxOfOrNull { it.priority } ?: 0) + 1)
val target = folder(modsDir, mod)
// No mod has this name, so a folder under it is what an interrupted import left.
target.deleteRecursively()
if (!staging.renameTo(target)) throw IOException("Could not move the mod into ${target.absolutePath}")
save(modsDir, mod)
return mod
} finally {
staging.deleteRecursively()
}
}
/** Unpacks a picked .zip into [destination], refusing entries that would land outside it. */
private fun unpack(source: Source, destination: File) {
// ZipFile reads the central directory, which every zip has; streaming fails on some.
val archive = File(destination, ".archive.zip")
try {
source.open().use { input -> archive.outputStream().use { input.copyTo(it) } }
ZipFile(archive).use { zip ->
for (entry in zip.entries()) {
if (entry.isDirectory) continue
val relative = entry.name.replace('\\', '/').trimStart('/')
if (relative.isEmpty() || relative.split('/').any { it == ".." }) {
throw IOException("${source.name} has a file outside its own folder (${entry.name}).")
}
val file = File(destination, relative)
file.parentFile?.mkdirs()
zip.getInputStream(entry).use { input -> file.outputStream().use { input.copyTo(it) } }
}
}
} finally {
archive.delete()
}
}
fun delete(modsDir: File, mod: Mod) {
val folder = folder(modsDir, mod)
if (folder.exists() && !folder.deleteRecursively()) throw IOException("Could not delete ${folder.absolutePath}")
}
/** ModManager.RenameModAsync: the folder and its `.ini` take the new name. */
fun rename(modsDir: File, mod: Mod, newTitle: String, mods: List<Mod>): Mod {
val name = newTitle.trim()
if (name == mod.title) return mod
validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") }
val renamed = mod.copy(title = name)
val from = folder(modsDir, mod)
val to = folder(modsDir, renamed)
if (!from.renameTo(to)) throw IOException("Could not rename ${from.absolutePath}")
File(to, "${mod.title}.ini").delete()
save(modsDir, renamed)
return renamed
}
/**
* ModManager.DecreasePriorityAsync (up) and IncreasePriorityAsync (down): the mod swaps
* priorities with its neighbour. Returns the two changed mods, or nothing at either end.
*/
fun move(mods: List<Mod>, mod: Mod, up: Boolean): List<Mod> {
val neighbour = if (up) {
mods.filter { it.priority < mod.priority }.maxByOrNull { it.priority }
} else {
mods.filter { it.priority > mod.priority }.minByOrNull { it.priority }
} ?: return emptyList()
return listOf(mod.copy(priority = neighbour.priority), neighbour.copy(priority = mod.priority))
}
// Launch
/**
* ModsLaunchService.PrepareModsForLaunch: the file name each enabled mod's files take in the
* Patches folder, and where each comes from. Mods are walked from the bottom of the list up and
* a later one replaces an earlier one's file, so the top of the list wins. Names compare
* without case, as on the PC and on the headset's shared storage.
*/
fun plan(modsDir: File, mods: List<Mod>): Map<String, File> {
val files = LinkedHashMap<String, Pair<String, File>>()
for (mod in mods.sortedWith(compareByDescending<Mod> { it.priority }.thenByDescending { it.title.lowercase(Locale.ROOT) })) {
if (!mod.enabled) continue
val folder = folder(modsDir, mod)
if (!folder.isDirectory) continue
val metadata = File(folder, "${mod.title}.ini").absolutePath
val contents = folder.walkTopDown()
.filter { it.isFile && !it.absolutePath.equals(metadata, ignoreCase = true) }
.sortedBy { it.relativeTo(folder).path.lowercase(Locale.ROOT) }
for (file in contents) {
val name = launchName(mod.priority, file.name)
val key = name.lowercase(Locale.ROOT)
files[key] = (files[key]?.first ?: name) to file
}
}
return files.values.associate { it }
}
/**
* ModsLaunchService.GetLaunchPatchFileName: a modding archive (`<name>.<tag>.szs`) is prefixed
* with its mod's priority, so Pulsar can resolve two mods patching the same archive.
*/
fun launchName(priority: Int, fileName: String): String {
if (!isModdingArchive(fileName)) return fileName
return "$priority.${stripPriorityPrefix(fileName)}"
}
private fun isModdingArchive(fileName: String): Boolean {
if (!fileName.endsWith(".szs", ignoreCase = true)) return false
val stem = fileName.substring(0, fileName.length - ".szs".length)
val separator = stem.lastIndexOf('.')
return separator > 0 && separator + 1 < stem.length
}
private fun stripPriorityPrefix(fileName: String): String {
val digits = fileName.takeWhile { it.isDigit() }.length
return if (digits > 0 && digits < fileName.length && fileName[digits] == '.') fileName.substring(digits + 1) else fileName
}
/** ModsLaunchService.ShouldAskToClearTargetFolder: no mod enabled, yet the Patches folder holds files. */
fun shouldAskToClear(mods: List<Mod>, patchesDir: File): Boolean =
mods.none { it.enabled } && patchesDir.listFiles { file -> file.isFile }.orEmpty().isNotEmpty()
/**
* ModsLaunchService.CopyFinalFiles: the Patches folder ends up holding exactly [plan]'s files.
* Loose files no mod provides go; a file whose size and time already match is not copied again.
*/
fun sync(patchesDir: File, plan: Map<String, File>) {
patchesDir.mkdirs()
if (!patchesDir.isDirectory) throw IOException("Could not create ${patchesDir.absolutePath}")
val wanted = plan.keys.map { it.lowercase(Locale.ROOT) }.toSet()
for (file in patchesDir.listFiles { file -> file.isFile }.orEmpty()) {
if (file.name.lowercase(Locale.ROOT) !in wanted && !file.delete()) {
throw IOException("Could not remove ${file.absolutePath}")
}
}
for ((name, source) in plan) {
val target = File(patchesDir, name)
if (target.isFile && target.length() == source.length() && target.lastModified() == source.lastModified()) continue
source.copyTo(target, overwrite = true)
// Without the time, every start would copy everything again; that is all it costs.
target.setLastModified(source.lastModified())
}
}
/** ModsLaunchService with the folder-clearing answer: null on success, otherwise the message. */
fun prepareForLaunch(modsDir: File, patchesDir: File, mods: List<Mod>, clear: Boolean): String? = try {
when {
mods.any { it.enabled } -> sync(patchesDir, plan(modsDir, mods))
clear && patchesDir.exists() && !patchesDir.deleteRecursively() ->
throw IOException("Could not clear ${patchesDir.absolutePath}")
}
null
} catch (e: IOException) {
e.message ?: e.toString()
}
// Background work
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "ModLibrary").apply { isDaemon = true } }
private val main by lazy { Handler(Looper.getMainLooper()) }
/** True while an import runs; imports and launch preparation take turns on one thread. */
@Volatile
var importing = false
private set
/** Runs [work] off the main thread, one task at a time, and hands its result to [done] on the main thread. */
fun <T> background(work: () -> T, done: (Result<T>) -> Unit) {
worker.execute {
val result = runCatching(work)
main.post { done(result) }
}
}
fun importInBackground(modsDir: File, title: String, sources: List<Source>, done: (Result<Mod>) -> Unit) {
importing = true
background({ import(modsDir, title, sources, load(modsDir)) }) { result ->
importing = false
done(result)
}
}
}
@@ -0,0 +1,359 @@
package org.wiicompiled.quest.launcher
import android.app.Activity
import android.app.AlertDialog
import android.content.res.ColorStateList
import android.net.Uri
import android.provider.OpenableColumns
import android.text.TextUtils
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.Switch
import android.widget.TextView
import android.widget.Toast
import java.io.File
import java.io.IOException
import kotlin.math.roundToInt
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.R
/**
* The launcher's Patches page, WheelWizard's mods page (Views/Pages/ModsPage.axaml.cs) without its
* mod browser: Import turns picked files into a named mod, and each mod can be switched on or off,
* moved up or down the list, renamed or deleted. What the list means for the game happens at
* Play, in [ModLibrary.prepareForLaunch].
*/
class PatchesPage(
private val activity: Activity,
root: View,
private val pickFiles: () -> Unit,
) {
private val empty: View = root.findViewById(R.id.patches_empty)
private val content: View = root.findViewById(R.id.patches_content)
private val rows: LinearLayout = root.findViewById(R.id.patches_rows)
private val count: TextView = root.findViewById(R.id.patches_count)
private val enableAll: Switch = root.findViewById(R.id.patches_enable_all)
private val headerImport: TextView = root.findViewById(R.id.patches_import)
private val importButtons = listOf(headerImport, root.findViewById<TextView>(R.id.patches_empty_import))
private val modsDir: File get() = GameStorage.modsDirectory(activity)
/** The list as read for the rows on screen, top first. */
private var mods: List<ModLibrary.Mod> = emptyList()
init {
for (button in importButtons) {
button.setOnClickListener { if (!ModLibrary.importing) pickFiles() }
}
styleSwitch(enableAll)
// A click, not a checked change: refresh() sets the switch without meaning to change every mod.
enableAll.setOnClickListener { setAllEnabled(enableAll.isChecked) }
root.findViewById<View>(R.id.patches_enable_all_label).setOnClickListener {
enableAll.toggle()
setAllEnabled(enableAll.isChecked)
}
}
/** Rebuilds the list from the Mods folder, which adb or the PC launcher's files may have changed. */
fun refresh() {
mods = ModLibrary.load(modsDir)
val hasMods = mods.isNotEmpty()
// As on the PC, the page's own Import moves to the top bar once there is a list.
empty.visibility = if (hasMods) View.GONE else View.VISIBLE
content.visibility = if (hasMods) View.VISIBLE else View.GONE
headerImport.visibility = if (hasMods) View.VISIBLE else View.GONE
count.text = mods.size.toString()
enableAll.isChecked = mods.all { it.enabled }
val importing = ModLibrary.importing
for (button in importButtons) {
button.isEnabled = !importing
button.alpha = if (importing) 0.45f else 1f
button.setText(if (importing) R.string.patches_importing else R.string.patches_import)
}
rows.removeAllViews()
if (!hasMods) return
rows.addView(
note(),
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(12)
},
)
mods.forEachIndexed { index, mod ->
rows.addView(
row(mod, index),
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
if (index > 0) topMargin = dp(3)
},
)
}
}
/** The files the player picked for Import: asks for the mod's name, then copies them in the background. */
fun importPicked(uris: List<Uri>) {
if (uris.isEmpty() || ModLibrary.importing) return
mods = ModLibrary.load(modsDir)
val sources = uris.map { uri ->
ModLibrary.Source(displayName(uri)) {
activity.contentResolver.openInputStream(uri) ?: throw IOException("Cannot read $uri")
}
}
// Typing on a headset is slow, so a single file offers its own name to start from.
val suggested = if (sources.size == 1) ModLibrary.suggestName(sources[0].name, mods) else ""
nameDialog(R.string.patches_name_title, null, suggested, R.string.patches_import, { ModLibrary.validateName(it, mods) }) { name ->
Log.i(TAG, "Importing ${sources.size} file(s) as mod $name")
ModLibrary.importInBackground(modsDir, name, sources) { result ->
if (activity.isDestroyed) return@importInBackground
refresh()
result.onSuccess { mod ->
Toast.makeText(activity, activity.getString(R.string.patches_installed, mod.title), Toast.LENGTH_LONG).show()
}.onFailure { failure ->
Log.w(TAG, "Mod import failed", failure)
AlertDialog.Builder(activity)
.setTitle(R.string.patches_import_failed)
.setMessage(failure.message ?: failure.toString())
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
refresh()
}
}
private fun row(mod: ModLibrary.Mod, index: Int): View {
val last = mods.size - 1
val toggle = Switch(activity).apply {
isChecked = mod.enabled
styleSwitch(this)
contentDescription = mod.title
setOnCheckedChangeListener { _, checked -> setModEnabled(mod, checked) }
}
val title = TextView(activity).apply {
text = mod.title
setTextColor(activity.getColor(R.color.neutral_100))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
isSingleLine = true
ellipsize = TextUtils.TruncateAt.END
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
minimumHeight = dp(58)
setPadding(dp(14), dp(6), dp(8), dp(6))
background = activity.getDrawable(
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
},
)
addView(toggle)
addView(title, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = dp(14)
marginEnd = dp(8)
})
addView(iconButton(R.drawable.ic_chevron_up, R.string.patches_move_up, index > 0) { move(mod, up = true) })
addView(iconButton(R.drawable.ic_chevron_down, R.string.patches_move_down, index < last) { move(mod, up = false) })
addView(iconButton(R.drawable.ic_more, R.string.patches_more, true) { anchor -> showMenu(anchor, mod) })
setOnClickListener { toggle.toggle() }
}
}
private fun iconButton(icon: Int, description: Int, enabled: Boolean, onClick: (View) -> Unit) = ImageView(activity).apply {
setImageResource(icon)
imageTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_300))
background = activity.getDrawable(R.drawable.bg_nav_item)
contentDescription = activity.getString(description)
setPadding(dp(10), dp(10), dp(10), dp(10))
isEnabled = enabled
alpha = if (enabled) 1f else 0.3f
setOnClickListener(onClick)
layoutParams = LinearLayout.LayoutParams(dp(42), dp(42))
}
private fun note() = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
background = activity.getDrawable(R.drawable.bg_banner_info)
setPadding(dp(14), dp(10), dp(14), dp(10))
addView(
TextView(activity).apply {
setText(R.string.patches_note)
setTextColor(activity.getColor(R.color.neutral_300))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
},
)
}
private fun showMenu(anchor: View, mod: ModLibrary.Mod) {
PopupMenu(activity, anchor).apply {
menu.add(0, MENU_RENAME, 0, R.string.patches_rename)
menu.add(0, MENU_DELETE, 1, R.string.patches_delete)
setOnMenuItemClickListener { item ->
when (item.itemId) {
MENU_RENAME -> rename(mod)
MENU_DELETE -> delete(mod)
}
true
}
show()
}
}
private fun setModEnabled(mod: ModLibrary.Mod, enabled: Boolean) {
// Saved in place rather than rebuilt, so the switch keeps its animation.
val changed = mod.copy(enabled = enabled)
if (!change { ModLibrary.save(modsDir, changed) }) {
refresh()
return
}
mods = mods.map { if (it.title == mod.title) changed else it }
enableAll.isChecked = mods.all { it.enabled }
}
private fun setAllEnabled(enabled: Boolean) {
change {
for (mod in mods) {
if (mod.enabled != enabled) ModLibrary.save(modsDir, mod.copy(enabled = enabled))
}
}
refresh()
}
private fun move(mod: ModLibrary.Mod, up: Boolean) {
change { ModLibrary.move(mods, mod, up).forEach { ModLibrary.save(modsDir, it) } }
refresh()
}
private fun rename(mod: ModLibrary.Mod) {
nameDialog(
R.string.patches_rename_title,
activity.getString(R.string.patches_rename_message, mod.title),
mod.title,
R.string.patches_rename,
{ name -> if (name.trim() == mod.title) null else ModLibrary.validateName(name, mods) },
) { name ->
change { ModLibrary.rename(modsDir, mod, name, mods) }
refresh()
}
}
private fun delete(mod: ModLibrary.Mod) {
AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.patches_delete_title, mod.title))
.setMessage(R.string.patches_delete_message)
.setPositiveButton(R.string.patches_delete) { _, _ ->
change { ModLibrary.delete(modsDir, mod) }
refresh()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
/** Runs one change to the Mods folder; false, with the reason shown, when it failed. */
private fun change(edit: () -> Unit): Boolean = try {
edit()
true
} catch (e: IOException) {
Log.w(TAG, "Mod change failed", e)
Toast.makeText(activity, activity.getString(R.string.patches_change_failed, e.message ?: e.toString()), Toast.LENGTH_LONG).show()
false
}
/**
* WheelWizard's TextInputWindow: a name field that keeps the dialog open, with the reason shown,
* until the name is one [validate] accepts.
*/
private fun nameDialog(
title: Int,
message: String?,
initial: String,
positive: Int,
validate: (String) -> ModLibrary.NameProblem?,
onAccept: (String) -> Unit,
) {
val input = EditText(activity).apply {
setText(initial)
setSelection(text.length)
setHint(R.string.patches_name_hint)
setTextColor(activity.getColor(R.color.neutral_100))
setHintTextColor(activity.getColor(R.color.neutral_500))
isSingleLine = true
imeOptions = EditorInfo.IME_ACTION_DONE
}
val container = FrameLayout(activity).apply {
setPadding(dp(22), dp(8), dp(22), 0)
addView(input)
}
val dialog = AlertDialog.Builder(activity)
.setTitle(title)
.apply { message?.let { setMessage(it) } }
.setView(container)
.setPositiveButton(positive, null)
.setNegativeButton(android.R.string.cancel, null)
.create()
fun accept() {
val problem = validate(input.text.toString())
if (problem != null) {
input.error = activity.getString(
when (problem) {
ModLibrary.NameProblem.Empty -> R.string.patches_name_empty
ModLibrary.NameProblem.Exists -> R.string.patches_name_exists
ModLibrary.NameProblem.IllegalCharacters -> R.string.patches_name_illegal
},
)
return
}
dialog.dismiss()
onAccept(input.text.toString().trim())
}
input.setOnEditorActionListener { _, action, _ ->
if (action == EditorInfo.IME_ACTION_DONE) accept()
action == EditorInfo.IME_ACTION_DONE
}
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { accept() }
input.requestFocus()
}
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
dialog.show()
}
private fun displayName(uri: Uri): String {
runCatching {
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotBlank() }?.let { return it }
}
}
return uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "file"
}
private fun styleSwitch(switch: Switch) {
switch.thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300)
switch.trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600)
}
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 dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt()
private companion object {
const val TAG = "WiiCompiledLauncher"
const val MENU_RENAME = 1
const val MENU_DELETE = 2
}
}
@@ -114,18 +114,28 @@ class SettingsPage(
}
private fun buildVr() {
val firstPerson = { c: TomlConfig -> c.bool("vr", "first_person") ?: false }
// Flat Screen mode keeps races on the menu screen, which none of the race view rows reach.
val immersive = { c: TomlConfig -> !(c.bool("vr", "flat_screen") ?: false) }
val firstPerson = { c: TomlConfig -> immersive(c) && (c.bool("vr", "first_person") ?: false) }
// The steering wheel and hand steering belong to the cockpit seat.
val cockpit = { c: TomlConfig -> firstPerson(c) && stringIndex(c, "vr", "first_person_seat", SEATS) == 0 }
section(R.string.section_vr_camera) {
toggle(
R.string.vr_flat_screen, R.string.vr_flat_screen_helper,
read = { !immersive(it) },
write = { c, value -> c.setBool("vr", "flat_screen", value) },
)
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 },
read = { if (it.bool("vr", "first_person") == true) 1 else 0 },
write = { c, index -> c.setBool("vr", "first_person", index == 1) },
enabledIf = immersive,
)
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) },
read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS, ROTATION_DEFAULT) },
write = { c, index -> c.setString("vr", "first_person_rotation", ROTATIONS[index]) },
enabledIf = firstPerson,
)
@@ -151,11 +161,26 @@ class SettingsPage(
},
enabledIf = firstPerson,
)
choice(
R.string.vr_seat, R.string.vr_seat_helper,
listOf(R.string.vr_seat_cockpit, R.string.vr_seat_custom),
read = { stringIndex(it, "vr", "first_person_seat", SEATS) },
write = { c, index -> c.setString("vr", "first_person_seat", SEATS[index]) },
enabledIf = firstPerson,
)
// heurazy's grab-and-turn wheel: runtime_config.h's kVrHandSteeringDefault is on.
toggle(
R.string.vr_hand_steering, R.string.vr_hand_steering_helper,
read = { it.bool("vr", "hand_steering") ?: true },
write = { c, value -> c.setBool("vr", "hand_steering", value) },
enabledIf = cockpit,
)
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) },
enabledIf = immersive,
)
}
section(R.string.section_vr_headset) {
@@ -187,6 +212,7 @@ class SettingsPage(
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) },
enabledIf = immersive,
)
slider(
R.string.vr_hud_distance, R.string.vr_hud_distance_helper, 0.5, 5.0, 0.1,
@@ -200,6 +226,11 @@ class SettingsPage(
format = { "%.1f m".format(it) },
write = { c, value -> c.setFloat("vr", "hud_width_meters", value) },
)
toggle(
R.string.vr_passthrough, R.string.vr_passthrough_helper,
read = { it.bool("vr", "passthrough") ?: true },
write = { c, value -> c.setBool("vr", "passthrough", value) },
)
}
}
@@ -227,6 +258,11 @@ class SettingsPage(
read = { it.bool("video", "skip_unready_pipelines") ?: true },
write = { c, value -> c.setBool("video", "skip_unready_pipelines", value) },
)
toggle(
R.string.graphics_gx_thread, R.string.graphics_gx_thread_helper,
read = { it.bool("video", "gx_thread") ?: true },
write = { c, value -> c.setBool("video", "gx_thread", value) },
)
}
}
@@ -290,6 +326,7 @@ class SettingsPage(
action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = idle) {
selectDiscImage()
}
info(R.string.about_disc_md5, activity.getString(R.string.disc_md5), stacked = true)
// One row per game this app carries a kit for, so both are visible at once.
for (profile in GameProfile.available(activity)) {
val manifest = GameLibrary.manifest(activity, profile)
@@ -356,6 +393,7 @@ class SettingsPage(
}
section(R.string.section_about_credits) {
info(R.string.about_credit_title_vr, activity.getString(R.string.about_credit_vr), stacked = true)
info(R.string.about_credit_title_hand_steering, activity.getString(R.string.about_credit_hand_steering), stacked = true)
info(R.string.about_credit_title_wiicompiled, activity.getString(R.string.about_credit_wiicompiled), stacked = true)
info(R.string.about_credit_title_retro_rewind, activity.getString(R.string.about_credit_retro_rewind), stacked = true)
info(R.string.about_credit_title_wheel_wizard, activity.getString(R.string.about_credit_wheel_wizard), stacked = true)
@@ -676,6 +714,11 @@ class SettingsPage(
private companion object {
val ROTATIONS = listOf("yaw", "yaw_pitch", "full")
/** runtime_config.h's kVrFirstPersonRotationDefault. */
val ROTATION_DEFAULT = ROTATIONS.indexOf("yaw_pitch")
// The runtime's default ("cockpit") first.
val SEATS = listOf("cockpit", "custom")
// The runtime's default ("boost") first: an absent key reads as index 0.
val PERFORMANCE_LEVELS = listOf("boost", "sustained_high", "sustained_low", "power_savings", "default")
val CONTROLLER_MODES = listOf("wii_remote", "gamepad")
@@ -697,8 +740,9 @@ class SettingsPage(
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 stringIndex(config: TomlConfig, section: String, key: String, values: List<String>,
default: Int = 0): Int =
values.indexOf(config.string(section, key)).takeIf { it >= 0 } ?: default
fun resolution(config: TomlConfig): Double =
config.number("video", "resolution_multiplier")?.takeIf { it in SUPPORTED_RESOLUTIONS } ?: 1.0
@@ -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="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="2.5"
android:pathData="M6,15L12,9L18,15" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's FileImport icon (Font Awesome, 512x512). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M128 64c0-35.3 28.7-64 64-64L352 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64l-256 0c-35.3 0-64-28.7-64-64l0-112 174.1 0-39 39c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l80-80c9.4-9.4 9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l39 39L128 288l0-224zm0 224l0 48L24 336c-13.3 0-24-10.7-24-24s10.7-24 24-24l104 0zM512 128l-128 0L384 0 512 128z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's ContextDots icon (Font Awesome, 128x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="192">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's CubesStacked icon (Font Awesome, 448x512), centred on a square. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:translateX="32">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M192 64l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32zM82.7 207c-15.3 8.8-20.5 28.4-11.7 43.7l32 55.4c8.8 15.3 28.4 20.5 43.7 11.7l55.4-32c15.3-8.8 20.5-28.4 11.7-43.7l-32-55.4c-8.8-15.3-28.4-20.5-43.7-11.7L82.7 207zM288 192c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0zm64 160c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0zM160 384l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32zM32 352c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0z" />
</group>
</vector>
@@ -61,6 +61,20 @@
android:text="@string/launcher_nav_home" />
</LinearLayout>
<LinearLayout
android:id="@+id/nav_patches"
style="@style/Launcher.NavItem">
<ImageView
style="@style/Launcher.NavIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_patches" />
<TextView
style="@style/Launcher.NavLabel"
android:text="@string/launcher_nav_patches" />
</LinearLayout>
<LinearLayout
android:id="@+id/nav_settings"
style="@style/Launcher.NavItem">
@@ -105,6 +119,11 @@
android:id="@+id/page_home"
layout="@layout/page_home" />
<include
android:id="@+id/page_patches"
layout="@layout/page_patches"
android:visibility="gone" />
<include
android:id="@+id/page_settings"
layout="@layout/page_settings"
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WheelWizard's Patches page (Views/Pages/ModsPage.axaml) without its mod browser. -->
<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">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="64dp">
<TextView
style="@style/Launcher.PageTitle"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="bottom"
android:paddingBottom="10dp"
android:text="@string/launcher_nav_patches" />
<TextView
android:id="@+id/patches_import"
style="@style/Launcher.ImportButton"
android:layout_gravity="bottom|end"
android:layout_marginBottom="10dp"
android:visibility="gone" />
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/patches_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_patches"
android:tint="@color/neutral_600" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-medium"
android:text="@string/patches_empty_title"
android:textColor="@color/neutral_200"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:maxWidth="440dp"
android:text="@string/patches_empty_message"
android:textColor="@color/neutral_400"
android:textSize="14sp" />
<TextView
android:id="@+id/patches_empty_import"
style="@style/Launcher.ImportButton"
android:layout_marginTop="18dp"
android:minWidth="190dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/patches_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="52dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
style="@style/Launcher.SectionLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/patches_mods" />
<TextView
android:id="@+id/patches_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/bg_pill"
android:paddingStart="10dp"
android:paddingTop="2dp"
android:paddingEnd="10dp"
android:paddingBottom="2dp"
android:textColor="@color/neutral_300"
android:textSize="13sp" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/patches_enable_all_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/patches_enable_all"
android:textColor="@color/neutral_200"
android:textSize="14sp" />
<Switch
android:id="@+id/patches_enable_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/neutral_700" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadeScrollbars="false"
android:scrollbarStyle="outsideOverlay">
<LinearLayout
android:id="@+id/patches_rows"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="12dp"
android:paddingBottom="28dp" />
</ScrollView>
</LinearLayout>
</FrameLayout>
</LinearLayout>
+52 -1
View File
@@ -8,6 +8,7 @@
<!-- Launcher shell -->
<string name="launcher_section_general">General</string>
<string name="launcher_nav_home">Home</string>
<string name="launcher_nav_patches">Patches</string>
<string name="launcher_nav_settings">Settings</string>
<string name="launcher_version">v%1$s</string>
@@ -97,6 +98,38 @@
<string name="about_reset_helper">Removes the game files, and if you tick them the built games and the Retro Rewind pack, so they can be set up again. Saves and settings stay.</string>
<string name="game_storage_failed">The game folder could not be prepared: %1$s</string>
<!-- Patches: WheelWizard's mods page, its wording from the PC launcher's en.yml -->
<string name="patches_import">Import</string>
<string name="patches_importing">Importing…</string>
<string name="patches_empty_title">No mods found</string>
<string name="patches_empty_message">Mods can alter how the game works. Start importing your first mod by clicking the button below.\n\nAs on the computer, mods change Retro Rewind. Import the mod\'s files, or the .zip it came in.</string>
<string name="patches_mods">Mods</string>
<string name="patches_enable_all">Enable all</string>
<string name="patches_note">Each time Retro Rewind starts, the enabled mods are copied into its Patches folder. When two mods replace the same file, the one higher in the list wins.</string>
<string name="patches_move_up">Move up</string>
<string name="patches_move_down">Move down</string>
<string name="patches_more">More</string>
<string name="patches_rename">Rename</string>
<string name="patches_delete">Delete</string>
<string name="patches_keep">Keep</string>
<string name="patches_name_title">Mod name:</string>
<string name="patches_name_hint">Enter mod name…</string>
<string name="patches_rename_title">Enter new name</string>
<string name="patches_rename_message">Changing name from: %1$s</string>
<string name="patches_name_empty">Mod name cannot be empty.</string>
<string name="patches_name_exists">Mod name already exists.</string>
<string name="patches_name_illegal">Mod name contains illegal characters.</string>
<string name="patches_installed">Mod \'%1$s\' installed successfully.</string>
<string name="patches_import_failed">The mod could not be imported</string>
<string name="patches_change_failed">The mod could not be changed: %1$s</string>
<string name="patches_delete_title">Are you sure you want to delete %1$s?</string>
<string name="patches_delete_message">Deleting is permanent and cannot be undone.</string>
<string name="patches_no_picker">This headset has no file picker to choose mod files with.</string>
<string name="patches_clear_title">Mods found</string>
<string name="patches_clear_message">You are about to launch the game without mods. Do you want to clear your Patches folder? If you keep it, the game starts with the patches it holds still active.</string>
<string name="home_installing_patches">Installing mods…</string>
<string name="home_patches_failed">The mods could not be installed</string>
<!-- Settings shell -->
<string name="settings_tab_vr">VR</string>
<string name="settings_tab_graphics">Graphics</string>
@@ -110,6 +143,7 @@
<string name="about_app_name">WiiCompiled OpenXR VR</string>
<string name="about_app_summary">Mario Kart Wii recompiled to native code, with an OpenXR renderer, running on this headset. No game code and no game data come with it: each game is built from the disc you own.</string>
<string name="about_credit_vr">Meta Quest port and the OpenXR VR renderer by iChris4.</string>
<string name="about_credit_hand_steering">The cockpit\'s turning steering wheel and hand steering by heurazy, from mario-kart-wii-VR-port (GPL v3).</string>
<string name="about_credit_wiicompiled">WiiCompiled, the static recompilation this is built on, by patchzyy and the static recompilation community.</string>
<string name="about_credit_retro_rewind">Retro Rewind by ZPL and team, the mod distribution this app can download and play.</string>
<string name="about_credit_wheel_wizard">Wheel Wizard by Patchzy and WantToBeeMe, the PC launcher this panel is modelled on.</string>
@@ -122,6 +156,7 @@
<string name="about_licence_text">GNU General Public License v3. Not affiliated with, endorsed by, or associated with Nintendo. Mario Kart Wii is a trademark of Nintendo.</string>
<string name="about_kit">Game kit</string>
<string name="about_credit_title_vr">Meta Quest and VR</string>
<string name="about_credit_title_hand_steering">Steering wheel and hand steering</string>
<string name="about_credit_title_wiicompiled">WiiCompiled</string>
<string name="about_credit_title_retro_rewind">Retro Rewind</string>
<string name="about_credit_title_wheel_wizard">Wheel Wizard</string>
@@ -139,6 +174,8 @@
<!-- VR tab -->
<string name="section_vr_camera">Camera</string>
<string name="vr_flat_screen">Flat Screen mode</string>
<string name="vr_flat_screen_helper">Plays races on the same flat screen as the menus instead of all around you. The camera settings below do not apply while it is on.</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>
@@ -153,6 +190,12 @@
<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_seat">Seat</string>
<string name="vr_seat_helper">The cockpit puts you at the driver\'s eyes, life-size, with the steering wheel or handlebar turning within reach. Custom uses the head offsets from the in-headset settings.</string>
<string name="vr_seat_cockpit">Cockpit</string>
<string name="vr_seat_custom">Custom</string>
<string name="vr_hand_steering">Hand steering</string>
<string name="vr_hand_steering_helper">In the cockpit, squeeze a grip near the steering wheel or handlebar to grab it, and turn it to steer. Releasing both grips gives steering back to the stick. Hand steering by heurazy.</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>
@@ -178,6 +221,8 @@
<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>
<string name="vr_passthrough">Passthrough around the menu screen</string>
<string name="vr_passthrough_helper">Shows your room through the headset\'s cameras around the menus instead of black. Races stay fully virtual, except in Flat Screen mode.</string>
<!-- Graphics tab -->
<string name="section_graphics">Rendering</string>
@@ -190,6 +235,8 @@
<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>
<string name="graphics_gx_thread">Graphics thread</string>
<string name="graphics_gx_thread_helper">Prepares the drawing on a second CPU core so busy scenes keep their speed. Turn off only to compare against the single-threaded path. Takes effect on the next launch.</string>
<!-- Controls tab -->
<string name="section_controls">Controllers</string>
@@ -213,7 +260,7 @@
<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_c_value">Right B</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>
@@ -240,6 +287,10 @@
<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>
<!-- MD5 of the clean PAL (RMCP01) disc as a plain .iso (Redump). -->
<string name="disc_md5" translatable="false">E7B1FF1FABB0789482CE2CB0661D986E</string>
<string name="disc_md5_note">Your disc image must be the clean PAL game: as a .iso file its MD5 hash is %1$s. Compressed images (WBFS, RVZ…) have a different hash.</string>
<string name="about_disc_md5">Clean PAL .iso MD5</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>
@@ -70,6 +70,24 @@
<item name="android:duplicateParentState">true</item>
</style>
<!-- The Patches page's Import, WheelWizard's Default button with its FileImport icon. -->
<style name="Launcher.ImportButton">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">40dp</item>
<item name="android:gravity">center</item>
<item name="android:paddingStart">16dp</item>
<item name="android:paddingEnd">18dp</item>
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:drawableStart">@drawable/ic_file_import</item>
<item name="android:drawablePadding">10dp</item>
<item name="android:drawableTint">@color/neutral_100</item>
<item name="android:text">@string/patches_import</item>
<item name="android:textColor">@color/neutral_100</item>
<item name="android:textSize">15sp</item>
<item name="android:clickable">true</item>
<item name="android:focusable">true</item>
</style>
<style name="Launcher.Tab">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">44dp</item>
@@ -0,0 +1,153 @@
package org.wiicompiled.quest.launcher
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/** The Patches page keeps mods as WheelWizard does and fills the Patches folder as it does at launch. */
class ModLibraryTest {
private fun temp(): File = Files.createTempDirectory("mods").toFile()
private fun source(name: String, text: String) = ModLibrary.Source(name) { text.byteInputStream() }
private fun zip(name: String, vararg entries: Pair<String, String>): ModLibrary.Source {
val bytes = ByteArrayOutputStream().also { out ->
ZipOutputStream(out).use { zip ->
for ((path, text) in entries) {
zip.putNextEntry(ZipEntry(path))
zip.write(text.toByteArray())
zip.closeEntry()
}
}
}.toByteArray()
return ModLibrary.Source(name) { bytes.inputStream() }
}
@Test
fun readsTheIniThePcWrites() {
// IniParser's output: CRLF, spaces around '=', .NET's True/False, sometimes a BOM.
val text = "[Mod]\r\nName = Fast Karts\r\nAuthor = someone\r\nModID = 4242\r\nIsEnabled = False\r\nPriority = 3\r\n"
assertEquals(ModLibrary.Mod("Fast Karts", enabled = false, priority = 3, author = "someone", modId = 4242), ModLibrary.parseIni(text))
// Absent values take the PC's defaults; a nameless file is no mod.
assertEquals(ModLibrary.Mod("Solo", enabled = true, priority = 0), ModLibrary.parseIni("[Mod]\nName=Solo\n"))
assertNull(ModLibrary.parseIni("[Mod]\nPriority = 1\n"))
val mod = ModLibrary.Mod("Round Trip", enabled = true, priority = 7, author = "a", modId = 9)
assertEquals(mod, ModLibrary.parseIni(ModLibrary.iniText(mod)))
assertTrue(ModLibrary.iniText(mod).contains("IsEnabled = True"))
}
@Test
fun namesFollowThePcRules() {
val mods = listOf(ModLibrary.Mod("Taken", true, 1))
assertEquals(ModLibrary.NameProblem.Empty, ModLibrary.validateName(" ", mods))
assertEquals(ModLibrary.NameProblem.Exists, ModLibrary.validateName("taken", mods))
assertEquals(ModLibrary.NameProblem.IllegalCharacters, ModLibrary.validateName("v1.2", mods))
assertEquals(ModLibrary.NameProblem.IllegalCharacters, ModLibrary.validateName("a:b", mods))
assertNull(ModLibrary.validateName(" New Mod ", mods))
assertEquals("Fire Kart", ModLibrary.suggestName("Fire Kart.tag.szs", mods))
assertEquals("", ModLibrary.suggestName("Taken.zip", mods))
}
@Test
fun importsLooseFilesAndZipsIntoOneMod() {
val modsDir = temp()
val first = ModLibrary.import(modsDir, " Karts ", listOf(source("Common.szs", "c"), zip("pack.zip", "Patches/Menu.szs" to "m")), emptyList())
assertEquals(ModLibrary.Mod("Karts", enabled = true, priority = 1), first)
assertEquals("c", File(modsDir, "Karts/Common.szs").readText())
assertEquals("m", File(modsDir, "Karts/Patches/Menu.szs").readText())
assertTrue(File(modsDir, "Karts/Karts.ini").isFile)
// A new mod goes below every existing one, and nothing is left of the staging folder.
val second = ModLibrary.import(modsDir, "Music", listOf(source("a.brstm", "a")), ModLibrary.load(modsDir))
assertEquals(2, second.priority)
assertEquals(listOf("Karts", "Music"), ModLibrary.load(modsDir).map { it.title })
assertEquals(setOf("Karts", "Music"), modsDir.list()!!.toSet())
}
@Test
fun refusesWhatItCannotImportAndLeavesNothing() {
val modsDir = temp()
for (sources in listOf(listOf(zip("evil.zip", "../escape.szs" to "x")), listOf(source("mod.rar", "r")), listOf(zip("empty.zip", "folder/" to "")))) {
try {
ModLibrary.import(modsDir, "Bad", sources, emptyList())
fail("imported ${sources.map { it.name }}")
} catch (expected: IOException) {
}
}
assertFalse(File(modsDir.parentFile, "escape.szs").exists())
assertEquals(0, modsDir.list()!!.size)
}
@Test
fun movingSwapsPriorityWithTheNeighbour() {
val mods = listOf(ModLibrary.Mod("A", true, 1), ModLibrary.Mod("B", true, 2), ModLibrary.Mod("C", true, 5))
assertEquals(listOf(ModLibrary.Mod("B", true, 1), ModLibrary.Mod("A", true, 2)), ModLibrary.move(mods, mods[1], up = true))
assertEquals(listOf(ModLibrary.Mod("B", true, 5), ModLibrary.Mod("C", true, 2)), ModLibrary.move(mods, mods[1], up = false))
assertTrue(ModLibrary.move(mods, mods[0], up = true).isEmpty())
}
@Test
fun renameMovesTheFolderAndItsIni() {
val modsDir = temp()
val mod = ModLibrary.import(modsDir, "Old", listOf(source("x.szs", "x")), emptyList())
val renamed = ModLibrary.rename(modsDir, mod, "New", ModLibrary.load(modsDir))
assertEquals(listOf(renamed), ModLibrary.load(modsDir))
assertTrue(File(modsDir, "New/x.szs").isFile)
assertFalse(File(modsDir, "New/Old.ini").exists())
}
@Test
fun modArchivesTakeTheirModsPriority() {
assertEquals("3.Kart.tag.szs", ModLibrary.launchName(3, "Kart.tag.szs"))
assertEquals("3.Kart.tag.szs", ModLibrary.launchName(3, "12.Kart.tag.szs"))
assertEquals("Common.szs", ModLibrary.launchName(3, "Common.szs"))
assertEquals("track.brstm", ModLibrary.launchName(3, "track.brstm"))
}
@Test
fun theTopOfTheListWinsAndDisabledModsAreLeftOut() {
val modsDir = temp()
val top = ModLibrary.import(modsDir, "Top", listOf(source("Common.szs", "top"), source("Top.brstm", "t")), emptyList())
val bottom = ModLibrary.import(modsDir, "Bottom", listOf(source("COMMON.szs", "bottom"), source("Bottom.brstm", "b")), listOf(top))
val off = ModLibrary.Mod("Off", enabled = false, priority = 3)
File(modsDir, "Off").mkdirs()
File(modsDir, "Off/Off.brstm").writeText("o")
ModLibrary.save(modsDir, off)
val plan = ModLibrary.plan(modsDir, ModLibrary.load(modsDir))
assertEquals(setOf("common.szs", "top.brstm", "bottom.brstm"), plan.keys.map { it.lowercase() }.toSet())
assertEquals("top", plan.entries.single { it.key.equals("common.szs", ignoreCase = true) }.value.readText())
assertEquals(listOf(top, bottom, off), ModLibrary.load(modsDir))
}
@Test
fun syncLeavesExactlyThePlanInPatches() {
val modsDir = temp()
val mod = ModLibrary.import(modsDir, "Mod", listOf(source("New.szs", "new")), emptyList())
val patches = File(temp(), "Patches").apply { mkdirs() }
File(patches, "Stale.szs").writeText("stale")
File(patches, "keep-folder").mkdirs()
ModLibrary.sync(patches, ModLibrary.plan(modsDir, listOf(mod)))
assertEquals(setOf("New.szs", "keep-folder"), patches.list()!!.toSet())
assertEquals("new", File(patches, "New.szs").readText())
// Nothing enabled: the folder is only cleared when the player agrees.
val disabled = listOf(mod.copy(enabled = false))
assertTrue(ModLibrary.shouldAskToClear(disabled, patches))
assertNull(ModLibrary.prepareForLaunch(modsDir, patches, disabled, clear = false))
assertTrue(File(patches, "New.szs").isFile)
assertNull(ModLibrary.prepareForLaunch(modsDir, patches, disabled, clear = true))
assertFalse(patches.exists())
assertFalse(ModLibrary.shouldAskToClear(disabled, patches))
}
}