mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 04:04:18 +02:00
Add PatchesPage functionality and related resources
This commit is contained in:
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)
|
||||
|
||||
/** 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")
|
||||
|
||||
@@ -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()
|
||||
@@ -549,18 +576,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +613,66 @@ class LauncherActivity : Activity() {
|
||||
}
|
||||
|
||||
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) {
|
||||
return
|
||||
}
|
||||
@@ -630,6 +718,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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -7,6 +7,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>
|
||||
|
||||
@@ -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="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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
`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 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
|
||||
|
||||
Reference in new issue
Block a user