mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 08:00:25 +02:00
Added Support for both Base and Retro Rewind in the launher
This commit is contained in:
1 parent
58c0890983
commit
321db74a0f
33 files changed
+733
-377
No files matched your search
@@ -42,6 +42,13 @@ val discPins: Map<String, String> = run {
|
||||
)
|
||||
}
|
||||
|
||||
// Whether the translation this build is pointed at includes the Retro Rewind mod, which decides
|
||||
// whether the kit can carry that product (emit-build-shards records it in shards.cmake).
|
||||
val hasRetroRewindShards: Boolean = run {
|
||||
val shards = File(mkwGeneratedDir ?: return@run false, "build_shards/shards.cmake")
|
||||
shards.isFile && shards.readText().contains("set(MKW_HAVE_RETRO_REWIND_SHARDS ON)")
|
||||
}
|
||||
|
||||
// android/nod-jni: nod, the disc image library the PC installer runs as nodtool,
|
||||
// cross-compiled with cargo for the launcher's "Select disc image". Needs a Rust
|
||||
// toolchain with the aarch64-linux-android target (see docs/quest-port.md).
|
||||
@@ -100,12 +107,23 @@ android {
|
||||
for ((name, value) in discPins) {
|
||||
buildConfigField("String", name, "\"$value\"")
|
||||
}
|
||||
// The APK carries game kits, not games: the player's libmain.so is built from their own
|
||||
// disc and loaded from private storage (see android/QuestGameKit.psm1). One app offers
|
||||
// both products, like the PC launcher, and the launcher's toggle picks which one to play.
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
buildConfigField("boolean", "ON_DEVICE_BUILD", "true")
|
||||
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
// One probe per product the translation can build. Retro Rewind's exists only
|
||||
// once translate-mod and emit-build-shards have run over the mod.
|
||||
targets += "mkw_quest_kit_probe"
|
||||
if (hasRetroRewindShards) {
|
||||
targets += "mkw_quest_kit_probe_retro"
|
||||
}
|
||||
arguments += listOf(
|
||||
"-DANDROID_STL=c++_shared",
|
||||
"-DANDROID_PLATFORM=android-29",
|
||||
@@ -121,33 +139,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "profile"
|
||||
productFlavors {
|
||||
create("base") {
|
||||
dimension = "profile"
|
||||
buildConfigField("String", "MAIN_LIBRARY", "\"main\"")
|
||||
buildConfigField("String", "PROFILE", "\"base\"")
|
||||
// The APK carries the game kit, not the game: the player's libmain.so is built from
|
||||
// their own disc and loaded from private storage (see android/QuestGameKit.psm1).
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
// And it carries the toolchain that builds it on the headset (Prepare-QuestToolchain.ps1).
|
||||
buildConfigField("boolean", "ON_DEVICE_BUILD", "true")
|
||||
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe" } }
|
||||
}
|
||||
create("retroRewind") {
|
||||
dimension = "profile"
|
||||
applicationIdSuffix = ".retrorewind"
|
||||
versionNameSuffix = "-retro-rewind"
|
||||
buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"")
|
||||
buildConfigField("String", "PROFILE", "\"retro_rewind\"")
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
// Retro Rewind is built on a PC and imported: building it on the headset would also
|
||||
// need the mod's Code.pul and its content there, which the launcher cannot fetch yet.
|
||||
buildConfigField("boolean", "ON_DEVICE_BUILD", "false")
|
||||
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe_retro" } }
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
// aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1;
|
||||
@@ -204,10 +195,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
|
||||
@get:Internal
|
||||
abstract val llvmStrip: RegularFileProperty
|
||||
|
||||
/** "base" or "retro_rewind": which product's kit this flavour carries. */
|
||||
@get:Internal
|
||||
abstract val product: Property<String>
|
||||
|
||||
@get:Inject
|
||||
abstract val execOperations: ExecOperations
|
||||
|
||||
@@ -219,11 +206,10 @@ abstract class ExportQuestGameKit : DefaultTask() {
|
||||
@TaskAction
|
||||
fun export() {
|
||||
val app = appDir.get().asFile
|
||||
val library = if (product.get() == "base") "libmkw_quest_kit_probe.so" else "libmkw_quest_kit_probe_retro.so"
|
||||
val probe = File(app, "build/intermediates/cxx").walkTopDown()
|
||||
.filter { it.name == library && it.parentFile.name == "arm64-v8a" }
|
||||
.filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
|
||||
.maxByOrNull { it.lastModified() }
|
||||
?: throw GradleException("No $library; the native build did not produce the game kit probe")
|
||||
?: throw GradleException("No libmkw_quest_kit_probe.so; the native build did not produce the game kit probe")
|
||||
val configuration = probe.parentFile.parentFile.parentFile // <BuildType>/<hash>
|
||||
val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a")
|
||||
if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir")
|
||||
@@ -232,7 +218,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
|
||||
commandLine(
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
|
||||
"-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path,
|
||||
"-Product", product.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -275,33 +260,27 @@ androidComponents {
|
||||
onVariants { variant ->
|
||||
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
|
||||
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
||||
// Both flavours ship a kit instead of the game. AGP packages every library in the CMake
|
||||
// output directory, so a game library left over from an earlier build would otherwise
|
||||
// travel in the APK.
|
||||
// The APK ships kits instead of games. AGP packages every library in the CMake output
|
||||
// directory, so a game library left over from an earlier build would otherwise travel in it.
|
||||
variant.packaging.jniLibs.excludes.add("**/libmain*.so")
|
||||
val export = tasks.register<ExportQuestGameKit>("export${capitalized}QuestGameKit") {
|
||||
dependsOn("merge${capitalized}NativeLibs")
|
||||
appDir.set(layout.projectDirectory)
|
||||
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
|
||||
product.set(if (variant.flavorName == "retroRewind") "retro_rewind" else "base")
|
||||
llvmStrip.set(sdkComponents.ndkDirectory.map {
|
||||
it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")
|
||||
})
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(export, ExportQuestGameKit::outputDir)
|
||||
|
||||
// Only the base flavour builds the game on the headset, so only it carries the toolchain:
|
||||
// Retro Rewind also needs the mod's own content and translation, which is a PC build for now.
|
||||
if (variant.flavorName != "retroRewind") {
|
||||
val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") {
|
||||
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
|
||||
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
|
||||
sources.from(fileTree(File(mkwRepoRoot, "translator/src")) { exclude("**/bin/**", "**/obj/**") })
|
||||
sources.from(fileTree(File(mkwRepoRoot, "Launcher/WiiCompiled.Setup.Common")) { exclude("**/bin/**", "**/obj/**") })
|
||||
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
|
||||
val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") {
|
||||
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
|
||||
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
|
||||
sources.from(fileTree(File(mkwRepoRoot, "translator/src")) { exclude("**/bin/**", "**/obj/**") })
|
||||
sources.from(fileTree(File(mkwRepoRoot, "Launcher/WiiCompiled.Setup.Common")) { exclude("**/bin/**", "**/obj/**") })
|
||||
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The player's compiled game: libmain.so built from their own disc against the game kit this
|
||||
* APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with.
|
||||
* A player's compiled game: libmain.so built from their own disc against the game kit this APK
|
||||
* carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with. One
|
||||
* per [GameProfile], so the unmodded game and Retro Rewind can both be installed.
|
||||
*
|
||||
* It lives in internal private storage, the one place Android lets an app load native code it
|
||||
* did not install (execution from shared or external storage is blocked). A library built for
|
||||
@@ -23,13 +24,16 @@ object GameLibrary {
|
||||
@Volatile
|
||||
private var cachedKitFingerprint: String? = null
|
||||
|
||||
fun directory(context: Context): File = File(context.filesDir, "game/${BuildConfig.PROFILE}")
|
||||
fun directory(context: Context, profile: GameProfile = GameProfile.selected(context)): File =
|
||||
File(context.filesDir, "game/${profile.id}")
|
||||
|
||||
fun library(context: Context): File = File(directory(context), LIBRARY_NAME)
|
||||
fun library(context: Context, profile: GameProfile = GameProfile.selected(context)): File =
|
||||
File(directory(context, profile), LIBRARY_NAME)
|
||||
|
||||
fun manifest(context: Context): JSONObject? = File(directory(context), MANIFEST_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
fun manifest(context: Context, profile: GameProfile = GameProfile.selected(context)): JSONObject? =
|
||||
File(directory(context, profile), MANIFEST_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
|
||||
/** The fingerprint of the kit in this APK's assets, which every game must be built against. */
|
||||
fun kitFingerprint(context: Context): String? {
|
||||
@@ -41,12 +45,9 @@ object GameLibrary {
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
fun status(context: Context): Status {
|
||||
if (!BuildConfig.EXTERNAL_GAME) {
|
||||
return Status.Ready
|
||||
}
|
||||
val manifest = manifest(context)
|
||||
if (manifest == null || !library(context).isFile) {
|
||||
fun status(context: Context, profile: GameProfile = GameProfile.selected(context)): Status {
|
||||
val manifest = manifest(context, profile)
|
||||
if (manifest == null || !library(context, profile).isFile) {
|
||||
return Status.Missing
|
||||
}
|
||||
return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.wiicompiled.quest
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The games this app can play, as the PC launcher offers them: the unmodded game and Retro Rewind.
|
||||
*
|
||||
* One APK carries a kit for each (`assets/game_kit`, see android/QuestGameKit.psm1) and the player
|
||||
* keeps a built game per profile in private storage, so switching between them is a toggle rather
|
||||
* than a second app. The choice is a file, not a preference, because the launcher and the game run
|
||||
* in different processes and the game reads it while starting.
|
||||
*/
|
||||
enum class GameProfile(val id: String, val title: Int, val modPack: Boolean) {
|
||||
Base("base", R.string.home_title_base, modPack = false),
|
||||
RetroRewind("retro_rewind", R.string.home_title_retro_rewind, modPack = true);
|
||||
|
||||
companion object {
|
||||
private const val SELECTION_FILE = "selected-game"
|
||||
|
||||
fun of(id: String?): GameProfile? = entries.firstOrNull { it.id == id }
|
||||
|
||||
/** The profiles this APK's kit can build and load, in launcher order. */
|
||||
fun available(context: Context): List<GameProfile> {
|
||||
val products = runCatching {
|
||||
context.assets.open("game_kit/kit.json").use { input ->
|
||||
JSONObject(input.reader().readText()).getJSONObject("products").keys().asSequence().toSet()
|
||||
}
|
||||
}.getOrDefault(setOf(Base.id))
|
||||
return entries.filter { it.id in products }.ifEmpty { listOf(Base) }
|
||||
}
|
||||
|
||||
/** The profile the player last chose, or the first available one. */
|
||||
fun selected(context: Context): GameProfile {
|
||||
val available = available(context)
|
||||
val stored = runCatching { File(context.filesDir, SELECTION_FILE).readText().trim() }.getOrNull()
|
||||
return of(stored)?.takeIf { it in available } ?: available.first()
|
||||
}
|
||||
|
||||
fun select(context: Context, profile: GameProfile) {
|
||||
File(context.filesDir, SELECTION_FILE).writeText(profile.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.wiicompiled.quest
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import org.wiicompiled.quest.launcher.TomlConfig
|
||||
|
||||
/**
|
||||
* Where the game's user files live on the headset. The launcher and the game
|
||||
@@ -30,9 +31,12 @@ object GameStorage {
|
||||
|
||||
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY)
|
||||
|
||||
/** Whether this app needs the Retro Rewind pack and has it. Always true for the base game. */
|
||||
fun modContentReady(context: Context): Boolean =
|
||||
BuildConfig.PROFILE != "retro_rewind" || File(modDirectory(context), "Binaries/Code.pul").isFile
|
||||
/** 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")
|
||||
|
||||
/** Whether the pack a profile needs is there. Always true for the unmodded game. */
|
||||
fun modContentReady(context: Context, profile: GameProfile): Boolean =
|
||||
!profile.modPack || modCodePul(context).isFile
|
||||
|
||||
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
|
||||
|
||||
@@ -58,7 +62,8 @@ object GameStorage {
|
||||
|
||||
/**
|
||||
* Creates the game directory and a first Config.toml with VR on and the
|
||||
* disc path filled in. An existing file is left alone: the launcher, the
|
||||
* disc path filled in. An existing file keeps every setting it has, and only
|
||||
* gains the Retro Rewind pack path when it predates it: the launcher, the
|
||||
* in-headset settings panel and the player all edit it in place.
|
||||
*/
|
||||
fun prepare(context: Context): File {
|
||||
@@ -75,10 +80,9 @@ object GameStorage {
|
||||
"[paths]",
|
||||
"dvd_root = \"${discDirectory(context).absolutePath}\"",
|
||||
)
|
||||
// The modded product reads its pack from retro_rewind_root, which sits next to DATA here.
|
||||
if (BuildConfig.PROFILE == "retro_rewind") {
|
||||
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
|
||||
}
|
||||
// The modded product reads its pack from retro_rewind_root, which sits next to DATA
|
||||
// here. Harmless for the unmodded game, and one config serves both.
|
||||
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
|
||||
lines += listOf(
|
||||
"",
|
||||
"[video]",
|
||||
@@ -90,6 +94,14 @@ object GameStorage {
|
||||
"render_scale = 1.0",
|
||||
)
|
||||
config.writeText(lines.joinToString("\n", postfix = "\n"))
|
||||
} else {
|
||||
// A config written before this app offered Retro Rewind names no pack, and the modded
|
||||
// game would find none. Only that one line is added; the rest is the player's.
|
||||
val toml = TomlConfig.parse(config.readText())
|
||||
if (toml.string("paths", "retro_rewind_root").isNullOrBlank()) {
|
||||
toml.setString("paths", "retro_rewind_root", modDirectory(context).absolutePath)
|
||||
config.writeText(toml.text())
|
||||
}
|
||||
}
|
||||
return gameRoot
|
||||
}
|
||||
|
||||
@@ -40,27 +40,26 @@ import org.libsdl.app.SDLSurface
|
||||
*/
|
||||
class QuestActivity : SDLActivity() {
|
||||
|
||||
override fun getLibraries(): Array<String> =
|
||||
if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
|
||||
/** The game the launcher's toggle last selected; read once, since a session plays one game. */
|
||||
private val profile: GameProfile by lazy { GameProfile.selected(this) }
|
||||
|
||||
override fun getLibraries(): Array<String> = arrayOf("SDL3")
|
||||
|
||||
/**
|
||||
* The game is not part of the APK: it is the libmain.so the player built from their own disc
|
||||
* ([GameLibrary]), loaded from private storage. Its libSDL3.so, libpng16.so and
|
||||
* libc++_shared.so dependencies resolve against the ones this APK installed.
|
||||
* ([GameLibrary]) for the selected profile, loaded from private storage. Its libSDL3.so,
|
||||
* libpng16.so and libc++_shared.so dependencies resolve against the ones this APK installed.
|
||||
*/
|
||||
override fun loadLibraries() {
|
||||
super.loadLibraries()
|
||||
if (BuildConfig.EXTERNAL_GAME) {
|
||||
// SDLActivity reports a failed load in its own error dialog and never starts the game.
|
||||
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
|
||||
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
|
||||
}
|
||||
System.load(GameLibrary.library(this).absolutePath)
|
||||
// SDLActivity reports a failed load in its own error dialog and never starts the game.
|
||||
if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
|
||||
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
|
||||
}
|
||||
System.load(GameLibrary.library(this, profile).absolutePath)
|
||||
}
|
||||
|
||||
override fun getMainSharedObject(): String =
|
||||
if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject()
|
||||
override fun getMainSharedObject(): String = GameLibrary.library(this, profile).absolutePath
|
||||
|
||||
override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context)
|
||||
|
||||
|
||||
@@ -60,6 +60,14 @@ object BuildRecipe {
|
||||
Regex("""\n\s*entry_points:\s*\n\s*-\s*(0x[0-9A-Fa-f]{8})""").find(manifest)?.groupValues?.get(1)
|
||||
?: throw IllegalArgumentException("recomp.yml names no translation entry point")
|
||||
|
||||
/**
|
||||
* Where recomp.yml's Retro Rewind profile expects the mod, relative to the workspace. A base
|
||||
* translation only accounts for a mod whose Code.pul is there.
|
||||
*/
|
||||
fun modRoot(manifest: String): String =
|
||||
Regex("""\n\s*mod_root:\s*(\S+)""").find(manifest)?.groupValues?.get(1)
|
||||
?: throw IllegalArgumentException("recomp.yml names no Retro Rewind mod_root")
|
||||
|
||||
/**
|
||||
* The blob assembly for ELF, as runtime/cmake/PublicProducts.cmake rewrites a Windows-generated
|
||||
* one. The headset's translator already writes ELF sections, so this normally changes nothing.
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ object GameBuild {
|
||||
}
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val KIT_SCHEMA = 2
|
||||
private const val KIT_SCHEMA = 3
|
||||
private const val TOOLCHAIN_ASSETS = "quest_toolchain"
|
||||
private const val KIT_ASSETS = "game_kit"
|
||||
private const val MARKER = ".complete"
|
||||
@@ -65,8 +66,8 @@ object GameBuild {
|
||||
private const val EXPECTED_TRANSLATION_SECONDS = 600.0
|
||||
private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024
|
||||
|
||||
/** Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
|
||||
/** Builds [profile]'s game. Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, profile: GameProfile, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
|
||||
if (!BuildConfig.ON_DEVICE_BUILD) {
|
||||
return "This app cannot build its game on the headset. Build it on a computer and use Import from computer."
|
||||
}
|
||||
@@ -74,6 +75,10 @@ object GameBuild {
|
||||
if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
|
||||
return "The game files (DATA) are needed to build the game. Select your disc image first."
|
||||
}
|
||||
if (!GameStorage.modContentReady(context, profile)) {
|
||||
return "Retro Rewind needs its pack before it can be built. Import a Retro Rewind game file that " +
|
||||
"carries it, or copy the RetroRewind6 folder to ${GameStorage.modDirectory(context).absolutePath}."
|
||||
}
|
||||
GameFiles.validateData(disc)?.let { return it }
|
||||
val root = File(context.filesDir, "build")
|
||||
root.mkdirs()
|
||||
@@ -85,7 +90,7 @@ object GameBuild {
|
||||
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val log = BuildLog(File(GameStorage.logsDirectory(context), "build_$stamp.log"))
|
||||
try {
|
||||
return Build(context, root, disc, reporter, cancelled, finishing, log).run()
|
||||
return Build(context, profile, root, disc, reporter, cancelled, finishing, log).run()
|
||||
} catch (e: InterruptedIOException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -99,6 +104,7 @@ object GameBuild {
|
||||
|
||||
private class Build(
|
||||
val context: Context,
|
||||
val profile: GameProfile,
|
||||
val root: File,
|
||||
val disc: File,
|
||||
val reporter: Reporter,
|
||||
@@ -109,24 +115,26 @@ object GameBuild {
|
||||
val toolchain = File(root, "toolchain")
|
||||
val ndk = File(root, "ndk")
|
||||
val kit = File(root, "kit")
|
||||
val work = File(root, GameLibrary.directory(context).name)
|
||||
val work = File(root, profile.id)
|
||||
val workspace = File(work, "ws")
|
||||
val objects = File(work, "obj")
|
||||
val temporary = File(root, "tmp")
|
||||
val failure = AtomicReference<String?>(null)
|
||||
|
||||
fun run(): String? {
|
||||
log.line("WiiCompiled Quest ${BuildConfig.VERSION_NAME} building ${BuildConfig.PROFILE} on this headset")
|
||||
log.line("WiiCompiled Quest ${BuildConfig.VERSION_NAME} building ${profile.id} on this headset")
|
||||
report(0, Step.Prepare)
|
||||
val toolchainManifest = JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/toolchain.json").reader().use { it.readText() })
|
||||
unpackToolchain(toolchainManifest)
|
||||
report(30, Step.Prepare)
|
||||
val kitFingerprint = GameLibrary.kitFingerprint(context) ?: return "This app carries no game kit."
|
||||
unpackKit(kitFingerprint)
|
||||
val recipe = JSONObject(File(kit, "kit.json").readText())
|
||||
if (recipe.optInt("schema") != KIT_SCHEMA) {
|
||||
return "This app's game kit is version ${recipe.optInt("schema")}, which this builder does not know."
|
||||
val kitJson = JSONObject(File(kit, "kit.json").readText())
|
||||
if (kitJson.optInt("schema") != KIT_SCHEMA) {
|
||||
return "This app's game kit is version ${kitJson.optInt("schema")}, which this builder does not know."
|
||||
}
|
||||
val recipe = kitJson.getJSONObject("products").optJSONObject(profile.id)
|
||||
?: return "This app's game kit cannot build ${profile.id}."
|
||||
report(40, Step.Prepare)
|
||||
downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() }))
|
||||
|
||||
@@ -141,8 +149,9 @@ object GameBuild {
|
||||
log,
|
||||
cancelled = { cancelled() || failure.get() != null },
|
||||
)
|
||||
translate(tools, "$kitFingerprint ${toolchainManifest.getString("fingerprint")}")?.let { return it }
|
||||
val objectsBySlot = compile(tools, recipe, toolchainManifest.getString("fingerprint"))
|
||||
val buildIdentity = "$kitFingerprint ${toolchainManifest.getString("fingerprint")}"
|
||||
translate(tools, buildIdentity)?.let { return it }
|
||||
val objectsBySlot = compile(tools, recipe, buildIdentity)
|
||||
failure.get()?.let { return it }
|
||||
val library = link(tools, recipe, objectsBySlot) ?: return failure.get() ?: "Linking the game failed."
|
||||
|
||||
@@ -271,7 +280,10 @@ object GameBuild {
|
||||
fun translate(tools: ToolProcess, identity: String): String? {
|
||||
val generated = File(workspace, "generated")
|
||||
val provenance = File(generated, "translation-provenance.txt")
|
||||
val expected = "$identity ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256}"
|
||||
// A modded game needs a base translation that knows this Code.pul, so the pack's own
|
||||
// identity is part of what the stored translation is reused for.
|
||||
val modIdentity = if (profile.modPack) BuildRecipe.hex(sha256(GameStorage.modCodePul(context))) else ""
|
||||
val expected = "$identity ${profile.id} ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256} $modIdentity"
|
||||
val shards = File(generated, "build_shards/shards.cmake")
|
||||
if (provenance.isFile && provenance.readText() == expected && shards.isFile) {
|
||||
log.line("Reusing the translation of an earlier build")
|
||||
@@ -286,9 +298,17 @@ object GameBuild {
|
||||
File(disc, DiscChecks.DOL_PATH).copyTo(File(workspace, "Assets/main.dol"), overwrite = true)
|
||||
File(disc, DiscChecks.REL_PATH).copyTo(File(workspace, "Assets/StaticR.rel"), overwrite = true)
|
||||
val manifest = "projects/mkwii/recomp.yml"
|
||||
val entryPoint = BuildRecipe.entryPoint(File(workspace, manifest).readText())
|
||||
val manifestText = File(workspace, manifest).readText()
|
||||
val entryPoint = BuildRecipe.entryPoint(manifestText)
|
||||
// The base translation only knows a mod's patches when the profile's Code.pul sits
|
||||
// where recomp.yml's mod_root says, so the pack's copy is staged there first.
|
||||
val steps = if (profile.modPack) 4 else 3
|
||||
if (profile.modPack) {
|
||||
val modRoot = File(workspace, BuildRecipe.modRoot(manifestText))
|
||||
GameStorage.modCodePul(context).copyTo(File(modRoot, "Binaries/Code.pul"), overwrite = true)
|
||||
}
|
||||
|
||||
report(50, Step.Translate, 1, 3)
|
||||
report(50, Step.Translate, 1, steps)
|
||||
val timing = Regex("""\(t=([0-9.]+)s\)""")
|
||||
translator(
|
||||
tools, "translating the game",
|
||||
@@ -299,22 +319,66 @@ object GameBuild {
|
||||
) { line ->
|
||||
timing.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { seconds ->
|
||||
val fraction = minOf(seconds / EXPECTED_TRANSLATION_SECONDS, 0.97)
|
||||
reporter.update(50 + (320 * fraction).toInt(), Step.Translate, 1, 3)
|
||||
reporter.update(50 + (300 * fraction).toInt(), Step.Translate, 1, steps)
|
||||
}
|
||||
}?.let { return it }
|
||||
report(370, Step.Translate, 2, 3)
|
||||
|
||||
// Retro Rewind's own code: its Code.pul translated against the base translation, as
|
||||
// Launcher/LocalBuild.ps1 does on a PC. Online play needs a payload this cannot fetch.
|
||||
val modOutput = "build/mods/retro_rewind_full_cpp"
|
||||
if (profile.modPack) {
|
||||
report(350, Step.Translate, 2, steps)
|
||||
translator(
|
||||
tools, "creating the base manifest",
|
||||
"emit-base-manifest", "--project", manifest, "--out", "build/base",
|
||||
"--functions-dir", "generated/functions",
|
||||
"--translation-output-metadata", "generated/base_translation_output.json", "--region", "P",
|
||||
)?.let { return it }
|
||||
translator(
|
||||
tools, "translating Retro Rewind",
|
||||
"translate-mod", "--project", manifest, "--profile", "retro-rewind",
|
||||
"--base-manifest", "build/base/mkwii_base_manifest.json",
|
||||
"--base-translation-output-metadata", "generated/base_translation_output.json",
|
||||
"--code-pul", GameStorage.modCodePul(context).absolutePath,
|
||||
"--mod-root", GameStorage.modDirectory(context).absolutePath,
|
||||
"--mod-name", "Retro Rewind", "--region", "P", "--out", modOutput,
|
||||
"--prefer-cached-inputs", "--emit-cpp", "--skip-retro-wfc",
|
||||
"--threads", TRANSLATOR_THREADS.toString(),
|
||||
)?.let { return it }
|
||||
}
|
||||
|
||||
report(370, Step.Translate, steps - 1, steps)
|
||||
translator(tools, "generating the game data", "generate-data-init", "--project", manifest, "--target-os", "android")?.let { return it }
|
||||
report(385, Step.Translate, 3, 3)
|
||||
translator(
|
||||
tools, "preparing the build",
|
||||
report(385, Step.Translate, steps, steps)
|
||||
val shardArguments = mutableListOf(
|
||||
"emit-build-shards", "--project", manifest, "--base-metadata", "generated/base_translation_output.json",
|
||||
"--base-functions-dir", "generated/functions", "--native-source-dir", "runtime/src", "--out", "generated/build_shards",
|
||||
)?.let { return it }
|
||||
)
|
||||
if (profile.modPack) {
|
||||
shardArguments += listOf(
|
||||
"--resolved-profile", "$modOutput/resolved_dispatch_profile.json",
|
||||
"--retro-cpp-dir", "$modOutput/cpp",
|
||||
)
|
||||
}
|
||||
translator(tools, "preparing the build", *shardArguments.toTypedArray())?.let { return it }
|
||||
provenance.writeText(expected)
|
||||
report(400, Step.Translate, 3, 3)
|
||||
report(400, Step.Translate, steps, steps)
|
||||
return null
|
||||
}
|
||||
|
||||
fun sha256(file: File): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest()
|
||||
}
|
||||
|
||||
fun translator(tools: ToolProcess, what: String, vararg arguments: String, onLine: (String) -> Unit = {}): String? {
|
||||
log.line("== translator ${arguments.first()}")
|
||||
val result = tools.run(
|
||||
@@ -332,11 +396,12 @@ object GameBuild {
|
||||
}
|
||||
|
||||
/** Compiles every generated source; returns their objects by link slot (runtime, product, translated). */
|
||||
fun compile(tools: ToolProcess, recipe: JSONObject, toolchainFingerprint: String): Map<String, List<String>> {
|
||||
// The identity is the kit's fingerprint and the toolchain's: the kit is fingerprinted as a
|
||||
// whole, and each game already compiles in its own work directory.
|
||||
fun compile(tools: ToolProcess, recipe: JSONObject, identity: String): Map<String, List<String>> {
|
||||
val generated = File(workspace, "generated")
|
||||
val shards = File(generated, "build_shards/shards.cmake").readText()
|
||||
val stamp = File(objects, ".stamp")
|
||||
val identity = "${recipe.getString("fingerprint")} $toolchainFingerprint"
|
||||
if (stamp.takeIf { it.isFile }?.readText() != identity) {
|
||||
objects.deleteRecursively()
|
||||
objects.mkdirs()
|
||||
@@ -481,7 +546,7 @@ object GameBuild {
|
||||
}
|
||||
|
||||
fun install(library: File, kitFingerprint: String): String? {
|
||||
val destination = GameLibrary.directory(context)
|
||||
val destination = GameLibrary.directory(context, profile)
|
||||
val staging = File(destination.parentFile, "${destination.name}.building")
|
||||
staging.deleteRecursively()
|
||||
staging.mkdirs()
|
||||
@@ -500,7 +565,7 @@ object GameBuild {
|
||||
val builtAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date())
|
||||
val manifest = JSONObject()
|
||||
.put("schema", 1)
|
||||
.put("profile", BuildConfig.PROFILE)
|
||||
.put("profile", profile.id)
|
||||
.put("gameId", BuildConfig.DISC_GAME_ID)
|
||||
.put("dolSha256", BuildConfig.DISC_DOL_SHA256)
|
||||
.put("relSha256", BuildConfig.DISC_REL_SHA256)
|
||||
|
||||
@@ -15,22 +15,25 @@ import java.util.zip.ZipInputStream
|
||||
import org.json.JSONObject
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
* A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest")
|
||||
* becomes this headset's game: libmain.so and game.json, and DATA too when the package carries
|
||||
* the extracted disc.
|
||||
* becomes one of this headset's games: libmain.so and game.json, plus DATA when the package
|
||||
* carries the extracted disc and the Retro Rewind pack when it carries that.
|
||||
*
|
||||
* The package is only accepted for the disc and app it was built for: game.json must name the
|
||||
* pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
|
||||
* in the package. Both parts are staged and checked before either replaces what is installed.
|
||||
* The package says which game it is (`profile`), so importing one installs that game and leaves
|
||||
* the other alone. It is only accepted for the disc and app it was built for: game.json must name
|
||||
* the pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
|
||||
* in the package. Every part is staged and checked before any of it replaces what is installed.
|
||||
*/
|
||||
object GamePackageImport {
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val SCHEMA = 1
|
||||
private const val DATA_PREFIX = "DATA/"
|
||||
private const val MOD_PREFIX = "MOD/"
|
||||
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
|
||||
|
||||
/** Null on success, otherwise the message to show. */
|
||||
@@ -42,21 +45,37 @@ object GamePackageImport {
|
||||
return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free."
|
||||
}
|
||||
|
||||
val libraryDir = GameLibrary.directory(context)
|
||||
val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing")
|
||||
// The game's destination is only known once game.json has been read, so the library is
|
||||
// staged under a name of its own first.
|
||||
val libraryStaging = File(context.filesDir, "game/.importing")
|
||||
val dataStaging = File(gameRoot, "DATA.importing")
|
||||
libraryStaging.deleteRecursively()
|
||||
dataStaging.deleteRecursively()
|
||||
val modStaging = File(gameRoot, "${GameStorage.MOD_DIRECTORY}.importing")
|
||||
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
|
||||
try {
|
||||
libraryStaging.mkdirs()
|
||||
var manifestText: String? = null
|
||||
var librarySha256: String? = null
|
||||
var hasData = false
|
||||
var hasMod = false
|
||||
|
||||
val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor))
|
||||
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
var lastReport = 0L
|
||||
fun extract(target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (counted.count - lastReport >= 8L shl 20) {
|
||||
lastReport = counted.count
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
val name = entry.name
|
||||
@@ -80,19 +99,11 @@ object GamePackageImport {
|
||||
}
|
||||
name.startsWith(DATA_PREFIX) -> {
|
||||
hasData = true
|
||||
val target = File(dataStaging, name.removePrefix(DATA_PREFIX))
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (counted.count - lastReport >= 8L shl 20) {
|
||||
lastReport = counted.count
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
extract(File(dataStaging, name.removePrefix(DATA_PREFIX)))
|
||||
}
|
||||
name.startsWith(MOD_PREFIX) -> {
|
||||
hasMod = true
|
||||
extract(File(modStaging, name.removePrefix(MOD_PREFIX)))
|
||||
}
|
||||
}
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
@@ -102,24 +113,34 @@ object GamePackageImport {
|
||||
finishing()
|
||||
val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?: return "This is not a WiiCompiled game file (game.json is missing)."
|
||||
val profile = GameProfile.of(manifestJson.optString("profile"))
|
||||
?.takeIf { it in GameProfile.available(context) }
|
||||
?: return "This game file is for ${manifestJson.optString("profile")}, which this app does not carry a kit for."
|
||||
manifestError(context, manifestJson, librarySha256)?.let { return it }
|
||||
if (hasData) {
|
||||
GameFiles.validateData(dataStaging)?.let { return it }
|
||||
} else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
|
||||
return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first."
|
||||
}
|
||||
if (hasMod && !File(modStaging, "Binaries/Code.pul").isFile) {
|
||||
return "The Retro Rewind pack in this game file is incomplete (no Binaries/Code.pul)."
|
||||
}
|
||||
|
||||
File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!)
|
||||
if (hasData) {
|
||||
GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it }
|
||||
}
|
||||
if (hasMod) {
|
||||
GameFiles.replace(GameStorage.modDirectory(context), modStaging)?.let { return it }
|
||||
}
|
||||
val libraryDir = GameLibrary.directory(context, profile)
|
||||
libraryDir.parentFile?.mkdirs()
|
||||
GameFiles.replace(libraryDir, libraryStaging)?.let { return it }
|
||||
Log.i(TAG, "Imported the game built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
|
||||
Log.i(TAG, "Imported ${profile.id} built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
|
||||
GameProfile.select(context, profile)
|
||||
return null
|
||||
} finally {
|
||||
libraryStaging.deleteRecursively()
|
||||
dataStaging.deleteRecursively()
|
||||
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +148,6 @@ object GamePackageImport {
|
||||
if (manifest.optInt("schema") != SCHEMA) {
|
||||
return "This game file comes from a newer or older launcher (format ${manifest.optInt("schema")}). Build it again with the current PC launcher."
|
||||
}
|
||||
if (manifest.optString("profile") != BuildConfig.PROFILE) {
|
||||
return "This game file is for ${manifest.optString("profile")}, but this app is ${BuildConfig.PROFILE}."
|
||||
}
|
||||
if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) ||
|
||||
!manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) ||
|
||||
!manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.InterruptedIOException
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
|
||||
/**
|
||||
* The long-running steps that make the game playable from what the player owns, one at a time,
|
||||
@@ -87,7 +88,7 @@ object GameSetup {
|
||||
}
|
||||
|
||||
/** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. [uri] is null only for a build. */
|
||||
fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean) {
|
||||
fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean, profile: GameProfile = GameProfile.selected(context)) {
|
||||
val progress = Progress { done, total ->
|
||||
publish(State.Working(task, done, total))
|
||||
!cancelRequested
|
||||
@@ -99,7 +100,7 @@ object GameSetup {
|
||||
publish(State.Working(task, permille.toLong(), 1000, step, done, total))
|
||||
!cancelRequested
|
||||
}
|
||||
GameBuild.run(context, reporter, cancelled = { cancelRequested }, finishing)
|
||||
GameBuild.run(context, profile, reporter, cancelled = { cancelRequested }, finishing)
|
||||
} else {
|
||||
uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor ->
|
||||
when (task) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlin.concurrent.thread
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
@@ -51,13 +52,14 @@ class GameSetupService : Service() {
|
||||
}
|
||||
GameSetup.addListener(listener)
|
||||
val deleteSource = intent?.getBooleanExtra(EXTRA_DELETE_SOURCE, false) ?: false
|
||||
val profile = GameProfile.of(intent?.getStringExtra(EXTRA_PROFILE)) ?: GameProfile.selected(this)
|
||||
|
||||
val wakeLock = getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
|
||||
wakeLock.acquire(if (task == GameSetup.Task.BuildGame) BUILD_WAKE_LOCK_TIMEOUT_MS else WAKE_LOCK_TIMEOUT_MS)
|
||||
thread(name = "GameSetup") {
|
||||
try {
|
||||
GameSetup.run(applicationContext, task, uri, deleteSource)
|
||||
GameSetup.run(applicationContext, task, uri, deleteSource, profile)
|
||||
} finally {
|
||||
uri?.let(::releaseReadPermission)
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
@@ -115,21 +117,24 @@ class GameSetupService : Service() {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFY_INTERVAL_MS = 1000L
|
||||
private const val EXTRA_TASK = "task"
|
||||
private const val EXTRA_PROFILE = "profile"
|
||||
private const val EXTRA_DELETE_SOURCE = "deleteSource"
|
||||
// Longer than any real task; only a hung run would reach it.
|
||||
private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000
|
||||
private const val BUILD_WAKE_LOCK_TIMEOUT_MS = 3L * 60 * 60 * 1000
|
||||
|
||||
/** Builds [profile]'s game from DATA on this headset ([GameBuild]). */
|
||||
fun startBuild(context: Context, profile: GameProfile) {
|
||||
val intent = Intent(context, GameSetupService::class.java)
|
||||
.putExtra(EXTRA_TASK, GameSetup.Task.BuildGame.name)
|
||||
.putExtra(EXTRA_PROFILE, profile.id)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts [task] on [uri]: a document the player picked, or a file in the Import folder,
|
||||
* which [deleteSource] removes once it has been imported.
|
||||
*/
|
||||
/** Builds the game from DATA on this headset ([GameBuild]). */
|
||||
fun startBuild(context: Context) {
|
||||
val intent = Intent(context, GameSetupService::class.java).putExtra(EXTRA_TASK, GameSetup.Task.BuildGame.name)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) {
|
||||
if (uri.scheme == "content") {
|
||||
try {
|
||||
|
||||
@@ -11,12 +11,15 @@ import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.QuestActivity
|
||||
import org.wiicompiled.quest.R
|
||||
@@ -56,8 +59,14 @@ class LauncherActivity : Activity() {
|
||||
private lateinit var dataBanner: View
|
||||
private lateinit var dataBannerIcon: ImageView
|
||||
private lateinit var dataBannerText: TextView
|
||||
private lateinit var homeTitle: TextView
|
||||
private lateinit var gameToggle: LinearLayout
|
||||
private lateinit var settings: SettingsPage
|
||||
|
||||
/** 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 var profile = GameProfile.Base
|
||||
|
||||
private var page = Page.Home
|
||||
private var launching = false
|
||||
private var trailsAway = true
|
||||
@@ -99,9 +108,9 @@ class LauncherActivity : Activity() {
|
||||
dataBannerIcon = findViewById(R.id.home_data_banner_icon)
|
||||
dataBannerText = findViewById(R.id.home_data_banner_text)
|
||||
|
||||
findViewById<TextView>(R.id.home_title).setText(
|
||||
if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base,
|
||||
)
|
||||
homeTitle = findViewById(R.id.home_title)
|
||||
gameToggle = findViewById(R.id.home_game_toggle)
|
||||
buildGameToggle()
|
||||
findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME)
|
||||
|
||||
settings = SettingsPage(
|
||||
@@ -131,13 +140,15 @@ class LauncherActivity : Activity() {
|
||||
!GameSetup.isRunning && GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready
|
||||
) {
|
||||
Log.i(TAG, "Starting a game build requested over adb")
|
||||
GameSetupService.startBuild(this)
|
||||
GameSetupService.startBuild(this, GameProfile.selected(this))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
launching = false
|
||||
// An import can install the other game and select it, so the toggle follows the file.
|
||||
profile = GameProfile.selected(this)
|
||||
importDroppedPackage()
|
||||
setupKind = GameSetup.state.javaClass
|
||||
refresh()
|
||||
@@ -198,7 +209,11 @@ class LauncherActivity : Activity() {
|
||||
private fun refreshHome() {
|
||||
val disc = GameStorage.discDirectory(this).absolutePath
|
||||
val discStatus = GameStorage.discStatus(this)
|
||||
val gameStatus = GameLibrary.status(this)
|
||||
val gameStatus = GameLibrary.status(this, profile)
|
||||
homeTitle.setText(profile.title)
|
||||
for ((index, entry) in profiles.withIndex()) {
|
||||
gameToggle.getChildAt(index).isSelected = entry == profile
|
||||
}
|
||||
val running = isGameRunning()
|
||||
val setup = GameSetup.state
|
||||
val settingUp = GameSetup.isRunning
|
||||
@@ -211,7 +226,7 @@ class LauncherActivity : Activity() {
|
||||
mainAction = when {
|
||||
running -> Action.Resume
|
||||
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
|
||||
gameStatus != GameLibrary.Status.Ready -> if (BuildConfig.ON_DEVICE_BUILD) Action.BuildGame else Action.ImportGame
|
||||
gameStatus != GameLibrary.Status.Ready -> Action.BuildGame
|
||||
else -> Action.Play
|
||||
}
|
||||
secondaryAction = when {
|
||||
@@ -280,14 +295,12 @@ class LauncherActivity : Activity() {
|
||||
setup is GameSetup.State.Failed && building -> showBanner(getString(R.string.home_build_failed, setup.message), warning = true)
|
||||
setup is GameSetup.State.Failed -> showBanner(getString(R.string.home_setup_failed, setup.message), warning = true)
|
||||
discStatus == GameStorage.DiscStatus.Incomplete -> showBanner(getString(R.string.home_data_incomplete, disc), warning = true)
|
||||
!GameStorage.modContentReady(this) ->
|
||||
!GameStorage.modContentReady(this, profile) ->
|
||||
showBanner(getString(R.string.home_mod_missing, GameStorage.modDirectory(this).absolutePath), warning = true)
|
||||
gameStatus == GameLibrary.Status.Stale ->
|
||||
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_stale else R.string.home_game_stale_pc), warning = true)
|
||||
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
|
||||
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
|
||||
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_setup_intro else R.string.home_setup_intro_pc), warning = false)
|
||||
gameStatus == GameLibrary.Status.Missing ->
|
||||
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_missing else R.string.home_game_missing_pc), warning = false)
|
||||
showBanner(getString(R.string.home_setup_intro), warning = false)
|
||||
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
|
||||
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
|
||||
else -> showBanner(null)
|
||||
}
|
||||
@@ -298,6 +311,40 @@ class LauncherActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home's game switch, shown only when this APK carries more than one kit. Each game keeps its
|
||||
* own built library, so switching is immediate; only the panel's state changes.
|
||||
*/
|
||||
private fun buildGameToggle() {
|
||||
for (entry in profiles) {
|
||||
val button = TextView(this).apply {
|
||||
setText(entry.title)
|
||||
textSize = 14f
|
||||
setTextColor(getColorStateList(R.color.game_toggle_text))
|
||||
setBackgroundResource(R.drawable.bg_game_toggle)
|
||||
gravity = android.view.Gravity.CENTER
|
||||
// One line: the pill has a fixed height, so a wrapped "Retro Rewind" loses half of itself.
|
||||
isSingleLine = true
|
||||
setPadding(10.dp(), 0, 10.dp(), 0)
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 38.dp())
|
||||
setOnClickListener { selectProfile(entry) }
|
||||
}
|
||||
gameToggle.addView(button)
|
||||
}
|
||||
gameToggle.visibility = if (profiles.size > 1) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun selectProfile(target: GameProfile) {
|
||||
if (target == profile || GameSetup.isRunning) return
|
||||
profile = target
|
||||
GameProfile.select(this, target)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun label(action: Action): Int = when (action) {
|
||||
Action.Play -> R.string.home_play
|
||||
Action.Resume -> R.string.home_resume
|
||||
@@ -325,12 +372,20 @@ class LauncherActivity : Activity() {
|
||||
GameBuild.Step.Prepare, null -> getString(R.string.build_step_prepare)
|
||||
}
|
||||
|
||||
/** Builds the game on this headset from DATA, after saying what that takes. */
|
||||
/** Builds the selected game on this headset from DATA, after saying what that takes. */
|
||||
private fun buildGame() {
|
||||
if (!BuildConfig.ON_DEVICE_BUILD || GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
|
||||
val message = if (GameLibrary.status(this) == GameLibrary.Status.Ready) R.string.home_build_replace_message else R.string.home_build_message
|
||||
if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
|
||||
if (!GameStorage.modContentReady(this, profile)) {
|
||||
confirm(R.string.home_mod_needed_title, R.string.home_mod_needed_message, R.string.home_import) { importGame() }
|
||||
return
|
||||
}
|
||||
val message = if (GameLibrary.status(this, profile) == GameLibrary.Status.Ready) {
|
||||
R.string.home_build_replace_message
|
||||
} else {
|
||||
R.string.home_build_message
|
||||
}
|
||||
confirm(R.string.home_build_title, message, R.string.home_build) {
|
||||
GameSetupService.startBuild(this)
|
||||
GameSetupService.startBuild(this, profile)
|
||||
showPage(Page.Home)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +417,7 @@ class LauncherActivity : Activity() {
|
||||
/** Opens the document picker for a .wcgame, asking first when it would replace the game. */
|
||||
private fun importGame() {
|
||||
if (GameSetup.isRunning) return
|
||||
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
|
||||
if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
|
||||
openPicker(REQUEST_GAME_PACKAGE)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.widget.Toast
|
||||
import kotlin.math.roundToInt
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
@@ -260,7 +261,7 @@ class SettingsPage(
|
||||
|
||||
private fun buildAbout() {
|
||||
section(R.string.section_about_app) {
|
||||
info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE))
|
||||
info(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
section(R.string.section_about_storage) {
|
||||
val disc = GameStorage.discDirectory(activity).absolutePath
|
||||
@@ -273,15 +274,18 @@ class SettingsPage(
|
||||
action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) {
|
||||
selectDiscImage()
|
||||
}
|
||||
val manifest = GameLibrary.manifest(activity)
|
||||
val gameText = when (GameLibrary.status(activity)) {
|
||||
GameLibrary.Status.Ready -> manifest?.let {
|
||||
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
|
||||
} ?: activity.getString(R.string.about_game_missing)
|
||||
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
|
||||
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
|
||||
// One row per game this app carries a kit for, so both are visible at once.
|
||||
for (profile in GameProfile.available(activity)) {
|
||||
val manifest = GameLibrary.manifest(activity, profile)
|
||||
val gameText = when (GameLibrary.status(activity, profile)) {
|
||||
GameLibrary.Status.Ready -> manifest?.let {
|
||||
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
|
||||
} ?: activity.getString(R.string.about_game_missing)
|
||||
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
|
||||
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
|
||||
}
|
||||
info(profile.title, gameText, stacked = true)
|
||||
}
|
||||
info(R.string.about_game, gameText, stacked = true)
|
||||
if (BuildConfig.ON_DEVICE_BUILD) {
|
||||
action(
|
||||
R.string.home_build, R.string.about_build_helper, R.string.home_build,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/neutral_950" android:state_selected="true" />
|
||||
<item android:color="@color/neutral_100" android:state_hovered="true" />
|
||||
<item android:color="@color/neutral_300" />
|
||||
</selector>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- One side of Home's game toggle: the selected game is filled, as WheelWizard's game switch is. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary_400" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="@android:color/transparent" />
|
||||
</selector>
|
||||
@@ -90,6 +90,16 @@
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/home_game_toggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:background="@drawable/bg_pill"
|
||||
android:orientation="horizontal"
|
||||
android:padding="3dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_title"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -21,12 +21,11 @@
|
||||
<string name="home_put_on_headset">The game opens in the headset.</string>
|
||||
<string name="home_data_missing">The game is ready, but its game files (DATA) are missing. Select your own PAL (RMCP01) disc image to extract them, or import a game file from your computer that includes them.\n\nThe game files go to:\n%1$s</string>
|
||||
<string name="home_import">Import from computer</string>
|
||||
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer.</string>
|
||||
<string name="home_game_missing">The game files are ready. Now build the game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string>
|
||||
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: each game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer. Retro Rewind also needs its pack, which a Retro Rewind game file can carry.</string>
|
||||
<string name="home_game_missing">The game files are ready. Now build this game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string>
|
||||
<string name="home_game_stale">This app was updated, so the game must be built again for it: press Build on this Quest, or use Build for Quest in the WiiCompiled VR launcher again and Import from computer.</string>
|
||||
<string name="home_setup_intro_pc">Nothing from Mario Kart Wii or Retro Rewind comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nCopy your disc image (ISO, WBFS, RVZ…) here, for example into Download, and press Select disc image. Then, on a computer, use Build for Quest in the WiiCompiled VR launcher with this app’s APK, copy the .wcgame file it makes to this headset and press Import from computer.</string>
|
||||
<string name="home_game_missing_pc">The game files are ready. The game itself is built on a computer: in the WiiCompiled VR launcher, use Build for Quest with this app’s APK, copy the .wcgame file to this headset, then press Import from computer.</string>
|
||||
<string name="home_game_stale_pc">This app was updated, so the game must be built again for it: use Build for Quest in the WiiCompiled VR launcher again, then Import from computer.</string>
|
||||
<string name="home_mod_needed_title">Retro Rewind needs its pack</string>
|
||||
<string name="home_mod_needed_message">Building Retro Rewind needs its pack (the RetroRewind6 folder) on this headset. Import a Retro Rewind game file built with the pack included, or copy the folder next to DATA yourself.</string>
|
||||
<string name="home_build">Build on this Quest</string>
|
||||
<string name="home_build_preparing">Preparing…</string>
|
||||
<string name="home_building">Building %1$d%%</string>
|
||||
@@ -165,7 +164,6 @@
|
||||
<!-- About tab -->
|
||||
<string name="section_about_app">App</string>
|
||||
<string name="about_version">Version</string>
|
||||
<string name="about_version_value">%1$s (%2$s)</string>
|
||||
<string name="section_about_storage">Files</string>
|
||||
<string name="about_game_data">Game data</string>
|
||||
<string name="about_game_data_ready">Found: %1$s</string>
|
||||
|
||||
@@ -11,6 +11,9 @@ import org.junit.Test
|
||||
*/
|
||||
class TomlConfigTest {
|
||||
|
||||
private val dvdRoot =
|
||||
"dvd_root = \"/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\""
|
||||
|
||||
private val sample = """
|
||||
# WiiCompiled Quest configuration.
|
||||
[paths]
|
||||
@@ -50,6 +53,21 @@ class TomlConfigTest {
|
||||
assertEquals(expected, config.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addsTheRetroRewindPackPathToAConfigThatPredatesIt() {
|
||||
// What GameStorage.prepare does to an install made before the app offered Retro Rewind:
|
||||
// the pack path joins [paths], and nothing else moves.
|
||||
val config = TomlConfig.parse(sample)
|
||||
assertNull(config.string("paths", "retro_rewind_root"))
|
||||
val pack = "/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/RetroRewind6"
|
||||
config.setString("paths", "retro_rewind_root", pack)
|
||||
assertEquals(
|
||||
sample.replace("$dvdRoot\n\n[video]", "$dvdRoot\nretro_rewind_root = \"$pack\"\n\n[video]"),
|
||||
config.text(),
|
||||
)
|
||||
assertEquals(pack, TomlConfig.parse(config.text()).string("paths", "retro_rewind_root"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendsAMissingSection() {
|
||||
val config = TomlConfig.parse(sample)
|
||||
|
||||
Reference in new issue
Block a user