Add PatchesPage functionality and related resources

This commit is contained in:
iChris4 committed 2026-09-22 03:54:10 +02:00
1 parent 4504a5a67d
commit 709fa89ed9
14 files changed
+1250 -9

No files matched your search

@@ -33,6 +33,9 @@ object GameStorage {
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY) 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. */ /** 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") fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul")
@@ -40,6 +43,11 @@ object GameStorage {
fun modContentReady(context: Context, profile: GameProfile): Boolean = fun modContentReady(context: Context, profile: GameProfile): Boolean =
!profile.modPack || modCodePul(context).isFile !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 configFile(context: Context): File = File(gameRoot(context), "Config.toml")
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs") fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
@@ -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), * 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, * 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 * 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() { class LauncherActivity : Activity() {
private enum class Page { Home, Settings } private enum class Page { Home, Patches, Settings }
/** What Home's main and secondary buttons do. */ /** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
private lateinit var navHome: View private lateinit var navHome: View
private lateinit var navPatches: View
private lateinit var navSettings: View private lateinit var navSettings: View
private lateinit var homePage: View private lateinit var homePage: View
private lateinit var patchesView: View
private lateinit var settingsView: View private lateinit var settingsView: View
private lateinit var trails: WheelTrailsView private lateinit var trails: WheelTrailsView
private lateinit var playButton: View private lateinit var playButton: View
@@ -64,6 +67,7 @@ class LauncherActivity : Activity() {
private lateinit var homeTitle: TextView private lateinit var homeTitle: TextView
private lateinit var gameToggle: LinearLayout private lateinit var gameToggle: LinearLayout
private lateinit var settings: SettingsPage private lateinit var settings: SettingsPage
private lateinit var patches: PatchesPage
/** The games this APK carries a kit for, and the one the player picked. */ /** The games this APK carries a kit for, and the one the player picked. */
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) } private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
@@ -71,6 +75,8 @@ class LauncherActivity : Activity() {
private var page = Page.Home private var page = Page.Home
private var launching = false 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 trailsAway = true
private var setupKind: Class<*>? = null private var setupKind: Class<*>? = null
private var mainAction = Action.Play private var mainAction = Action.Play
@@ -95,8 +101,10 @@ class LauncherActivity : Activity() {
} }
navHome = findViewById(R.id.nav_home) navHome = findViewById(R.id.nav_home)
navPatches = findViewById(R.id.nav_patches)
navSettings = findViewById(R.id.nav_settings) navSettings = findViewById(R.id.nav_settings)
homePage = findViewById(R.id.page_home) homePage = findViewById(R.id.page_home)
patchesView = findViewById(R.id.page_patches)
settingsView = findViewById(R.id.page_settings) settingsView = findViewById(R.id.page_settings)
trails = findViewById(R.id.home_trails) trails = findViewById(R.id.home_trails)
playButton = findViewById(R.id.home_play) playButton = findViewById(R.id.home_play)
@@ -129,11 +137,15 @@ class LauncherActivity : Activity() {
downloadModPack = ::downloadModPack, downloadModPack = ::downloadModPack,
resetInstallation = ::resetInstallation, resetInstallation = ::resetInstallation,
) )
patches = PatchesPage(this, patchesView) {
openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker)
}
savedInstanceState?.getString(KEY_TAB)?.let { name -> savedInstanceState?.getString(KEY_TAB)?.let { name ->
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
} }
navHome.setOnClickListener { showPage(Page.Home) } navHome.setOnClickListener { showPage(Page.Home) }
navPatches.setOnClickListener { showPage(Page.Patches) }
navSettings.setOnClickListener { showPage(Page.Settings) } navSettings.setOnClickListener { showPage(Page.Settings) }
playButton.setOnClickListener { perform(mainAction) } playButton.setOnClickListener { perform(mainAction) }
secondary.setOnClickListener { secondaryAction?.let(::perform) } secondary.setOnClickListener { secondaryAction?.let(::perform) }
@@ -182,8 +194,16 @@ class LauncherActivity : Activity() {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
val uri = data?.data ?: return if (resultCode != RESULT_OK || data == null) return
if (resultCode != RESULT_OK) 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) { val task = when (requestCode) {
REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc
REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage
@@ -197,8 +217,10 @@ class LauncherActivity : Activity() {
private fun showPage(target: Page) { private fun showPage(target: Page) {
page = target page = target
navHome.isSelected = target == Page.Home navHome.isSelected = target == Page.Home
navPatches.isSelected = target == Page.Patches
navSettings.isSelected = target == Page.Settings navSettings.isSelected = target == Page.Settings
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE 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 settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
if (target == Page.Home) { if (target == Page.Home) {
trailsAway = true trailsAway = true
@@ -209,6 +231,7 @@ class LauncherActivity : Activity() {
private fun refresh() { private fun refresh() {
when (page) { when (page) {
Page.Home -> refreshHome() Page.Home -> refreshHome()
Page.Patches -> patches.refresh()
Page.Settings -> settings.refresh() Page.Settings -> settings.refresh()
} }
} }
@@ -278,10 +301,14 @@ class LauncherActivity : Activity() {
setup is GameSetup.State.Finishing -> getString(R.string.home_finishing) setup is GameSetup.State.Finishing -> getString(R.string.home_finishing)
else -> getString(label(mainAction)) 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 secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE
secondaryAction?.let { secondary.setText(label(it)) } 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 progress.isIndeterminate = setup !is GameSetup.State.Working
if (setup is GameSetup.State.Working && setup.total > 0) { if (setup is GameSetup.State.Working && setup.total > 0) {
progress.progress = (setup.done * progress.max / setup.total).toInt() progress.progress = (setup.done * progress.max / setup.total).toInt()
@@ -549,18 +576,19 @@ class LauncherActivity : Activity() {
.show() .show()
} }
private fun openPicker(requestCode: Int) { private fun openPicker(requestCode: Int, multiple: Boolean = false, noPicker: Int = R.string.home_no_picker) {
// Disc images and game files have no reliable MIME type, so every file is offered // Disc images, game files and mod files have no reliable MIME type, so every file is
// and the task checks the name and contents. // offered and the task checks the name and contents.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE) .addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*") .setType("*/*")
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple)
try { try {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
startActivityForResult(intent, requestCode) startActivityForResult(intent, requestCode)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
Log.w(TAG, "No document picker", e) 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()
} }
} }
@@ -585,6 +613,66 @@ class LauncherActivity : Activity() {
} }
private fun play() { private fun play() {
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) { if (launching) {
return return
} }
@@ -630,6 +718,7 @@ class LauncherActivity : Activity() {
const val PROCESS_EXIT_GRACE_MS = 1000L const val PROCESS_EXIT_GRACE_MS = 1000L
const val REQUEST_DISC_IMAGE = 1 const val REQUEST_DISC_IMAGE = 1
const val REQUEST_GAME_PACKAGE = 2 const val REQUEST_GAME_PACKAGE = 2
const val REQUEST_PATCH_FILES = 3
const val PREFERENCES = "launcher" const val PREFERENCES = "launcher"
const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport"
const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME" 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
}
}
@@ -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" /> android:text="@string/launcher_nav_home" />
</LinearLayout> </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 <LinearLayout
android:id="@+id/nav_settings" android:id="@+id/nav_settings"
style="@style/Launcher.NavItem"> style="@style/Launcher.NavItem">
@@ -105,6 +119,11 @@
android:id="@+id/page_home" android:id="@+id/page_home"
layout="@layout/page_home" /> layout="@layout/page_home" />
<include
android:id="@+id/page_patches"
layout="@layout/page_patches"
android:visibility="gone" />
<include <include
android:id="@+id/page_settings" android:id="@+id/page_settings"
layout="@layout/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>
@@ -7,6 +7,7 @@
<!-- Launcher shell --> <!-- Launcher shell -->
<string name="launcher_section_general">General</string> <string name="launcher_section_general">General</string>
<string name="launcher_nav_home">Home</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_nav_settings">Settings</string>
<string name="launcher_version">v%1$s</string> <string name="launcher_version">v%1$s</string>
@@ -95,6 +96,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="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> <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 --> <!-- Settings shell -->
<string name="settings_tab_vr">VR</string> <string name="settings_tab_vr">VR</string>
<string name="settings_tab_graphics">Graphics</string> <string name="settings_tab_graphics">Graphics</string>
@@ -70,6 +70,24 @@
<item name="android:duplicateParentState">true</item> <item name="android:duplicateParentState">true</item>
</style> </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"> <style name="Launcher.Tab">
<item name="android:layout_width">wrap_content</item> <item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">44dp</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))
}
}
+16
View File
@@ -173,6 +173,22 @@ mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR
logging). The launch-time geometry (`render_scale`, `hud_distance_meters`, logging). The launch-time geometry (`render_scale`, `hud_distance_meters`,
`hud_width_meters`) is only reachable here, not from the in-headset panel. `hud_width_meters`) is only reachable here, not from the in-headset panel.
**Patches**, between the two, is the PC launcher's mods page without its mod
browser (`PatchesPage`, `ModLibrary`). Import takes one or more picked files,
asks for a name and makes them one mod under `WiiCompiledOpenXRVR/Mods/<name>/`
with the PC's `<name>.ini` (Name, Author, ModID, IsEnabled, Priority), so a
`Mods` folder copied from WheelWizard reads the same. Unlike the PC's Import, a
picked `.zip` is unpacked, since there is no browser to install downloaded mods;
`.7z` and `.rar` are refused. Each mod can be switched off, moved up or down,
renamed or deleted. As on the PC, mods only change Retro Rewind: its Play first
flattens the enabled mods into `RetroRewind6/Patches` exactly like
`ModsLaunchService.PrepareModsForLaunch` (the top of the list wins a file both
carry, `<name>.<tag>.szs` archives take their mod's priority as a prefix, and
loose files no mod provides are removed). With no mod enabled, a Patches folder
that still holds files is only cleared if the player says so. The pack's
Riivolution XML maps that folder onto `/patches` and `/sound`. `ModLibraryTest`
covers the rules.
The launcher follows the runtime's rules exactly. `TomlConfig` edits one line The launcher follows the runtime's rules exactly. `TomlConfig` edits one line
the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file, the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,
so values the in-headset panel wrote are kept. Each row reads its key with the so values the in-headset panel wrote are kept. Each row reads its key with the