Introduced translator_host C program to start the WiiCompiled translator on the headset, managing heap pointer tagging and dynamic library loading.

This commit is contained in:
iChris4 committed 2026-09-17 20:33:43 +02:00
1 parent 2f85207c39
commit 0bf2675db1
19 files changed
+1796 -60

No files matched your search

@@ -0,0 +1,89 @@
package org.wiicompiled.quest.launcher
import java.security.MessageDigest
/**
* The text-in, text-out parts of building the game on the headset ([GameBuild]): reading the
* translator's outputs and turning the game kit's recipe (kit.json) into compiler and linker
* command lines. They mirror android/QuestGameKit.psm1, which does the same on a PC.
*/
object BuildRecipe {
/** The translated source lists emit-build-shards writes into shards.cmake, in link order. */
val TRANSLATED_LISTS = listOf("MKW_BASE_COMMON_SHARDS", "MKW_BASE_PORTABLE_SENSITIVE_SHARDS")
const val REGISTRATION_LIST = "MKW_BASE_REGISTRATION_SOURCES"
/**
* One argument of a response file for clang or lld on Android, which read them with GNU
* quoting: a backslash escapes the next character.
*/
fun responseFileArgument(argument: String): String = buildString {
for (c in argument) {
if (c == '\\' || c == '"' || c == '\'' || c.isWhitespace()) append('\\')
append(c)
}
}
fun responseFile(arguments: List<String>): String =
arguments.joinToString(separator = "\n", postfix = "\n") { responseFileArgument(it) }
/** Replaces the kit recipe's {name} placeholders; an unknown placeholder is an error. */
fun expand(argument: String, values: Map<String, String>): String {
var result = argument
for ((name, value) in values) result = result.replace("{$name}", value)
PLACEHOLDER.find(result)?.let { throw IllegalArgumentException("Unexpanded ${it.value} in the game kit recipe") }
return result
}
/**
* The link.lld arguments with every {game:slot} replaced by that slot's object files
* (runtime, product, translated) and the other placeholders expanded.
*/
fun linkArguments(template: List<String>, values: Map<String, String>, objects: Map<String, List<String>>): List<String> {
val arguments = ArrayList<String>(template.size + objects.values.sumOf { it.size })
for (argument in template) {
val slot = GAME_SLOT.matchEntire(argument)?.groupValues?.get(1)
if (slot != null) {
arguments += objects[slot] ?: throw IllegalArgumentException("The game kit recipe names an unknown slot {game:$slot}")
} else {
arguments += expand(argument, values)
}
}
return arguments
}
/** The quoted entries of `set(NAME ...)` in a shards.cmake, or an empty list without it. */
fun sourceList(shardsCmake: String, name: String): List<String> {
val body = Regex("""set\(${Regex.escape(name)}\s(.*?)\)""", RegexOption.DOT_MATCHES_ALL).find(shardsCmake)
?.groupValues?.get(1) ?: return emptyList()
return Regex("\"([^\"]+)\"").findAll(body).map { it.groupValues[1].replace('\\', '/') }.toList()
}
/** The first translation entry point in recomp.yml, as the translator expects it (0x...). */
fun entryPoint(manifest: String): String =
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")
/**
* 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.
*/
fun elfBlobAssembly(text: String): String {
val elf = text.replace(".section .rdata,\"dr\"", ".section .rodata,\"a\",@progbits")
return if (elf == text) text else elf + "\n.section .note.GNU-stack,\"\",@progbits\n"
}
/** An object file name for a generated source: its stem, which the translator keeps unique. */
fun objectName(source: String): String = source.substringAfterLast('/').substringBeforeLast('.') + ".o"
/** SHA-256 over "path sha256" lines sorted by path (ordinal), joined with \n. */
fun listDigest(files: Map<String, String>): String {
val lines = files.entries.map { "${it.key} ${it.value}" }.sorted()
return hex(MessageDigest.getInstance("SHA-256").digest(lines.joinToString("\n").toByteArray(Charsets.UTF_8)))
}
fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }
private val PLACEHOLDER = Regex("""\{[a-z:]+\}""")
private val GAME_SLOT = Regex("""\{game:(\w+)\}""")
}
@@ -0,0 +1,493 @@
package org.wiicompiled.quest.launcher
import android.app.ActivityManager
import android.content.Context
import android.os.StatFs
import android.util.Log
import java.io.File
import java.io.IOException
import java.io.InterruptedIOException
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.zip.ZipInputStream
import org.json.JSONArray
import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameStorage
/**
* Builds the game on the headset from the player's extracted disc, the way the PC launcher's
* Build for Quest does (android/Build-QuestGame.ps1), for players without a PC:
*
* 1. unpack the toolchain (assets/quest_toolchain, android/Prepare-QuestToolchain.ps1) and the
* game kit (assets/game_kit) into private storage;
* 2. download the Android NDK files the build needs from Google, checked against the pins the
* toolchain carries (ndk.json);
* 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards;
* 4. compile the generated sources with the kit's flags, several at a time;
* 5. link them with the kit's objects and archives (kit.json link.lld);
* 6. install libmain.so and its game.json like an imported game.
*
* About half an hour on a Quest 3. Work survives a cancelled or failed run: a translation whose
* inputs have not changed is reused and finished objects are not compiled again. A successful
* build removes everything it unpacked and generated.
*/
object GameBuild {
enum class Step { Prepare, Download, Translate, Compile, Link, Install }
/** [permille] of the whole build; [done] of [total] items in [step]. False stops the build. */
fun interface Reporter {
fun update(permille: Int, step: Step, done: Int, total: Int): Boolean
}
private const val TAG = "WiiCompiledLauncher"
private const val TOOLCHAIN_ASSETS = "quest_toolchain"
private const val KIT_ASSETS = "game_kit"
private const val MARKER = ".complete"
private const val REQUIRED_SPACE = 2_500_000_000L
// Translation peaks near 3.1 GB of memory with the runtime's default heap; this limit keeps it
// near 2.1 GB for the same output, at some cost in time.
private const val MONO_GC_PARAMS = "soft-heap-limit=1200m"
private const val TRANSLATOR_THREADS = 4
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? {
val disc = GameStorage.discDirectory(context)
if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
return "The game files (DATA) are needed to build the game. Select your disc image first."
}
GameFiles.validateData(disc)?.let { return it }
val root = File(context.filesDir, "build")
root.mkdirs()
val available = StatFs(root.absolutePath).availableBytes
if (available < REQUIRED_SPACE) {
return "Not enough free space: building needs ${GameFiles.gigabytes(REQUIRED_SPACE)}, and ${GameFiles.gigabytes(available)} is free."
}
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()
} catch (e: InterruptedIOException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Game build failed", e)
log.line("Build failed: $e")
return "${e.message ?: e.toString()}\n\nThe build log is ${log.file.absolutePath}"
} finally {
log.close()
}
}
private class Build(
val context: Context,
val root: File,
val disc: File,
val reporter: Reporter,
val cancelled: () -> Boolean,
val finishing: () -> Unit,
val log: BuildLog,
) {
val toolchain = File(root, "toolchain")
val ndk = File(root, "ndk")
val kit = File(root, "kit")
val work = File(root, GameLibrary.directory(context).name)
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")
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())
report(40, Step.Prepare)
downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() }))
temporary.mkdirs()
val tools = ToolProcess(
mapOf(
"LD_LIBRARY_PATH" to File(toolchain, "llvm/lib").absolutePath,
"HOME" to temporary.absolutePath,
"TMPDIR" to temporary.absolutePath,
"MONO_GC_PARAMS" to MONO_GC_PARAMS,
),
log,
cancelled = { cancelled() || failure.get() != null },
)
translate(tools, "$kitFingerprint ${toolchainManifest.getString("fingerprint")}")?.let { return it }
val objectsBySlot = compile(tools, recipe, toolchainManifest.getString("fingerprint"))
failure.get()?.let { return it }
val library = link(tools, recipe, objectsBySlot) ?: return failure.get() ?: "Linking the game failed."
finishing()
report(990, Step.Install)
install(library, kitFingerprint)?.let { return it }
log.line("Installed the game built on this headset")
// Everything above is only needed again after an app update, which changes the kit.
if (!root.deleteRecursively()) Log.w(TAG, "Could not remove all of ${root.absolutePath}")
return null
}
fun report(permille: Int, step: Step, done: Int = 0, total: Int = 0) {
if (!reporter.update(permille, step, done, total) || cancelled()) throw InterruptedIOException("Build cancelled")
}
/** Unpacks files.zip, which holds exactly the files toolchain.json lists. */
fun unpackToolchain(manifest: JSONObject) {
val fingerprint = manifest.getString("fingerprint")
if (File(toolchain, MARKER).takeIf { it.isFile }?.readText() == fingerprint) return
toolchain.deleteRecursively()
val listed = manifest.getJSONArray("files")
val sizes = HashMap<String, Long>()
for (i in 0 until listed.length()) {
val file = listed.getJSONObject(i)
sizes[file.getString("path")] = file.getLong("size")
}
var unpacked = 0
ZipInputStream(context.assets.open("$TOOLCHAIN_ASSETS/${manifest.getString("archive")}").buffered(1 shl 20)).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.isDirectory) continue
val expected = sizes[entry.name] ?: throw IOException("The app's toolchain holds an unlisted file ${entry.name}.")
val target = File(toolchain, entry.name)
target.parentFile?.mkdirs()
target.outputStream().use { zip.copyTo(it, 1 shl 20) }
if (target.length() != expected) throw IOException("The app's toolchain file ${entry.name} is damaged.")
unpacked++
report(30 * unpacked / sizes.size, Step.Prepare)
}
}
if (unpacked != sizes.size) throw IOException("The app's toolchain is incomplete.")
File(toolchain, MARKER).writeText(fingerprint)
}
fun unpackKit(fingerprint: String) {
if (File(kit, MARKER).takeIf { it.isFile }?.readText() == fingerprint) return
kit.deleteRecursively()
copyAssetTree(KIT_ASSETS, kit)
File(kit, MARKER).writeText(fingerprint)
}
fun copyAssetTree(asset: String, target: File) {
val children = context.assets.list(asset).orEmpty()
if (children.isEmpty()) {
copyAsset(asset, target)
return
}
for (child in children) copyAssetTree("$asset/$child", File(target, child))
}
fun copyAsset(asset: String, target: File) {
target.parentFile?.mkdirs()
context.assets.open(asset).use { input -> target.outputStream().use { input.copyTo(it, 1 shl 20) } }
}
/** The NDK subset ndk.json lists, from Google's zip, each file checked against its SHA-256. */
fun downloadNdk(subset: JSONObject) {
val digest = subset.getString("digest")
if (File(ndk, MARKER).takeIf { it.isFile }?.readText() == digest) return
report(40, Step.Download)
ndk.deleteRecursively()
val url = subset.getString("url")
val prefix = subset.getString("prefix")
val expected = HashMap<String, String>()
val listed: JSONArray = subset.getJSONArray("files")
for (i in 0 until listed.length()) {
val file = listed.getJSONObject(i)
expected[prefix + file.getString("path")] = file.getString("sha256")
}
log.line("Downloading ${expected.size} Android NDK files from $url")
val zip = RemoteZip(subset.getLong("size")) { start, end -> fetchRange(url, start, end) }
val wanted = zip.entries().filter { it.name in expected }
if (wanted.size != expected.size) throw IOException("Google's Android NDK download no longer has the expected files.")
val totalBytes = wanted.sumOf { it.end - it.offset }
var doneBytes = 0L
val actual = HashMap<String, String>()
zip.read(wanted) { entry, bytes ->
val sha256 = BuildRecipe.hex(MessageDigest.getInstance("SHA-256").digest(bytes))
if (sha256 != expected[entry.name]) throw IOException("The downloaded Android NDK file ${entry.name} is damaged. Try again.")
val path = entry.name.removePrefix(prefix)
File(ndk, path).apply { parentFile?.mkdirs() }.writeBytes(bytes)
actual[path] = sha256
doneBytes += entry.end - entry.offset
report(40 + (10 * doneBytes / totalBytes).toInt(), Step.Download, (doneBytes shr 20).toInt(), (totalBytes shr 20).toInt())
}
if (BuildRecipe.listDigest(actual) != digest) throw IOException("The downloaded Android NDK files do not match this app's pins.")
File(ndk, MARKER).writeText(digest)
}
fun fetchRange(url: String, start: Long, end: Long): ByteArray {
if (cancelled()) throw InterruptedIOException("Build cancelled")
val connection = URL(url).openConnection() as HttpURLConnection
try {
connection.connectTimeout = 30_000
connection.readTimeout = 60_000
connection.setRequestProperty("Range", "bytes=$start-$end")
// Android asks for gzip by default, and Google's server then serves a gzip-encoded
// zip whose ranges are not the file's (HTTP 416 near the end).
connection.setRequestProperty("Accept-Encoding", "identity")
val code = try {
connection.responseCode
} catch (e: IOException) {
throw IOException("The Android NDK files could not be downloaded from Google. Check the headset's internet connection.", e)
}
if (code != HttpURLConnection.HTTP_PARTIAL) throw IOException("Google's server answered $code to the Android NDK download.")
val bytes = connection.inputStream.use { it.readBytes() }
if (bytes.size.toLong() != end - start + 1) throw IOException("The Android NDK download was cut short. Try again.")
return bytes
} finally {
connection.disconnect()
}
}
/** Translates the disc unless this workspace already holds a translation of the same inputs. */
fun translate(tools: ToolProcess, identity: String): String? {
val generated = File(workspace, "generated")
val provenance = File(generated, "translation-provenance.txt")
val expected = "$identity ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256}"
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")
report(400, Step.Translate)
return null
}
provenance.delete()
val project = File(workspace, "projects/mkwii")
File(kit, "translation/projects/mkwii").copyRecursively(project, overwrite = true)
File(workspace, "runtime").deleteRecursively()
File(kit, "translation/runtime").copyRecursively(File(workspace, "runtime"), overwrite = true)
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())
report(50, Step.Translate, 1, 3)
val timing = Regex("""\(t=([0-9.]+)s\)""")
translator(
tools, "translating the game",
"translate-recursive", entryPoint, "--project", manifest, "--outdir", "generated/functions",
"--output-metadata", "generated/base_translation_output.json",
"--production-source-bundle", "generated/base_translation_sources.bin",
"--no-function-files", "--prune-stale", "--threads", TRANSLATOR_THREADS.toString(),
) { 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)
}
}?.let { return it }
report(370, Step.Translate, 2, 3)
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",
"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 }
provenance.writeText(expected)
report(400, Step.Translate, 3, 3)
return null
}
fun translator(tools: ToolProcess, what: String, vararg arguments: String, onLine: (String) -> Unit = {}): String? {
log.line("== translator ${arguments.first()}")
val result = tools.run(
workspace,
File(toolchain, "translator/translator_host"),
listOf(File(toolchain, "translator").absolutePath, "Translator.Cli") + arguments,
onLine,
)
if (result.exitCode == 0) return null
return if (result.killed) {
"The headset ran out of memory while $what. Close other apps, then build again."
} else {
"The translator failed while $what (exit ${result.exitCode}).\n${result.tail.takeLast(6).joinToString("\n")}"
}
}
/** 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>> {
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()
stamp.writeText(identity)
}
val blob = File(generated, "data_sections_init_blobs.S")
val elfBlob = File(work, "data_sections_init_blobs_android.S")
val blobText = blob.readText()
val elfText = BuildRecipe.elfBlobAssembly(blobText)
if (!elfBlob.isFile || elfBlob.readText() != elfText) elfBlob.writeText(elfText)
data class Job(val kind: String, val source: File, val slot: String)
val jobs = ArrayList<Job>()
jobs += Job("runtime", File(generated, "data_sections_init.cpp"), "runtime")
jobs += Job("runtime", File(generated, "guest_symbol_table.cpp"), "runtime")
jobs += Job("asm", elfBlob, "runtime")
BuildRecipe.sourceList(shards, BuildRecipe.REGISTRATION_LIST).forEach { jobs += Job("product", File(it), "product") }
val translated = BuildRecipe.TRANSLATED_LISTS.flatMap { BuildRecipe.sourceList(shards, it) }
if (translated.isEmpty()) throw IOException("The translation produced no game code.")
translated.forEach { jobs += Job("translated", File(it), "translated") }
if (jobs.map { BuildRecipe.objectName(it.source.path) }.toSet().size != jobs.size) {
throw IOException("Two generated sources share an object name.")
}
val values = mapOf(
"kit" to kit.absolutePath,
"sysroot" to File(ndk, "sysroot").absolutePath,
"workspace" to workspace.absolutePath,
)
val compileFlags = recipe.getJSONObject("compile")
for (kind in listOf("runtime", "asm", "product", "translated")) {
val flags = compileFlags.getJSONArray(kind).let { array -> (0 until array.length()).map { BuildRecipe.expand(array.getString(it), values) } }
File(work, "$kind.rsp").writeText(BuildRecipe.responseFile(flags))
}
val clang = File(toolchain, "llvm/bin/clang-21")
val resourceDir = File(toolchain, "llvm/lib/clang/21").absolutePath
val pending = jobs.filter { job ->
val obj = File(objects, BuildRecipe.objectName(job.source.path))
!(obj.isFile && obj.length() > 0 && obj.lastModified() >= job.source.lastModified())
}
val finished = AtomicInteger(jobs.size - pending.size)
log.line("== compiling ${pending.size} of ${jobs.size} sources")
val pool = Executors.newFixedThreadPool(compileJobs())
try {
val futures: List<Future<*>> = pending.map { job ->
pool.submit {
if (failure.get() != null || cancelled()) return@submit
val obj = File(objects, BuildRecipe.objectName(job.source.path))
val partial = File(objects, obj.name + ".partial")
// The blob assembly needs no preprocessing, and preprocessing would make clang
// start itself again, which Android does not allow here.
val language = if (job.kind == "asm") listOf("--driver-mode=gcc", "-x", "assembler") else listOf("--driver-mode=g++")
val result = tools.run(
work, clang,
language + listOf(
"-resource-dir", resourceDir, "@" + File(work, "${job.kind}.rsp").absolutePath,
"-c", job.source.absolutePath, "-o", partial.absolutePath,
),
)
if (result.exitCode != 0) {
partial.delete()
failure.compareAndSet(
null,
if (result.killed) "The headset ran out of memory while compiling the game. Close other apps, then build again."
else "Compiling ${job.source.name} failed.\n${result.tail.takeLast(6).joinToString("\n")}",
)
return@submit
}
if (!partial.renameTo(obj)) failure.compareAndSet(null, "Could not store ${obj.name}.")
finished.incrementAndGet()
}
}
while (futures.any { !it.isDone }) {
report(400 + 570 * finished.get() / jobs.size, Step.Compile, finished.get(), jobs.size)
TimeUnit.MILLISECONDS.sleep(500)
}
futures.forEach { future ->
try {
future.get()
} catch (e: java.util.concurrent.ExecutionException) {
val cause = e.cause
// A failed compile stops the others, which is not the player cancelling.
if (cause is InterruptedIOException && failure.get() == null) throw cause
failure.compareAndSet(null, cause?.message ?: cause.toString())
}
}
} finally {
pool.shutdownNow()
}
if (failure.get() == null) report(970, Step.Compile, jobs.size, jobs.size)
return jobs.groupBy({ it.slot }, { File(objects, BuildRecipe.objectName(it.source.path)).absolutePath })
}
fun compileJobs(): Int {
val memory = ActivityManager.MemoryInfo()
context.getSystemService(ActivityManager::class.java)?.getMemoryInfo(memory)
val byMemory = (memory.availMem / COMPILE_MEMORY_BYTES).toInt()
return minOf(4, Runtime.getRuntime().availableProcessors(), byMemory).coerceAtLeast(1)
}
fun link(tools: ToolProcess, recipe: JSONObject, objectsBySlot: Map<String, List<String>>): File? {
report(970, Step.Link)
val library = File(work, recipe.getString("output"))
library.delete()
val template = recipe.getJSONObject("link").getJSONArray("lld").let { array -> (0 until array.length()).map { array.getString(it) } }
val arguments = BuildRecipe.linkArguments(
template,
mapOf("kit" to kit.absolutePath, "ndk" to ndk.absolutePath, "output" to library.absolutePath),
objectsBySlot,
)
val rsp = File(work, "link.rsp").apply { writeText(BuildRecipe.responseFile(arguments)) }
log.line("== linking")
val result = tools.run(work, File(toolchain, "llvm/bin/ld.lld"), listOf("@" + rsp.absolutePath))
if (result.exitCode != 0 || !library.isFile) {
failure.compareAndSet(null, "Linking the game failed.\n${result.tail.takeLast(6).joinToString("\n")}")
return null
}
return library
}
fun install(library: File, kitFingerprint: String): String? {
val destination = GameLibrary.directory(context)
val staging = File(destination.parentFile, "${destination.name}.building")
staging.deleteRecursively()
staging.mkdirs()
try {
val installed = File(staging, GameLibrary.LIBRARY_NAME)
library.copyTo(installed)
val digest = MessageDigest.getInstance("SHA-256")
installed.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)
}
}
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("gameId", BuildConfig.DISC_GAME_ID)
.put("dolSha256", BuildConfig.DISC_DOL_SHA256)
.put("relSha256", BuildConfig.DISC_REL_SHA256)
.put("kitFingerprint", kitFingerprint)
.put("library", GameLibrary.LIBRARY_NAME)
.put("librarySha256", BuildRecipe.hex(digest.digest()))
.put("includesData", false)
.put("builtBy", "this headset (WiiCompiled Quest ${BuildConfig.VERSION_NAME})")
.put("builtAt", builtAt)
File(staging, GameLibrary.MANIFEST_NAME).writeText(manifest.toString(2))
destination.parentFile?.mkdirs()
return GameFiles.replace(destination, staging)
} finally {
staging.deleteRecursively()
}
}
}
}
@@ -15,18 +15,27 @@ import java.io.InterruptedIOException
* - [Task.ExtractDisc]: the player's disc image becomes DATA ([DiscExtraction]).
* - [Task.ImportPackage]: a .wcgame built on a PC becomes the game library, and DATA too when
* the package carries it ([GamePackageImport]).
* - [Task.BuildGame]: the game library is built on the headset from DATA ([GameBuild]).
*
* Every task stages its output next to the destination and swaps it in only after it has been
* checked, so a failed, cancelled or killed run never costs working files.
*/
object GameSetup {
enum class Task { ExtractDisc, ImportPackage }
enum class Task { ExtractDisc, ImportPackage, BuildGame }
sealed interface State {
data object Idle : State
data class Checking(val task: Task) : State
data class Working(val task: Task, val done: Long, val total: Long) : State
/** A build also reports its [step] and how many of that step's items are [stepDone]. */
data class Working(
val task: Task,
val done: Long,
val total: Long,
val step: GameBuild.Step? = null,
val stepDone: Int = 0,
val stepTotal: Int = 0,
) : State
data class Finishing(val task: Task) : State
data class Done(val task: Task) : State
data class Failed(val task: Task, val message: String) : State
@@ -77,25 +86,33 @@ object GameSetup {
}
}
/** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. */
fun run(context: Context, task: Task, uri: Uri, deleteSource: Boolean) {
/** 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) {
val progress = Progress { done, total ->
publish(State.Working(task, done, total))
!cancelRequested
}
val finishing = { publish(State.Finishing(task)) }
try {
val failure = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
when (task) {
Task.ExtractDisc -> DiscExtraction.run(context, descriptor.fd, displayName(context, uri), progress, finishing)
Task.ImportPackage -> GamePackageImport.run(context, descriptor, progress, finishing)
val failure = if (task == Task.BuildGame) {
val reporter = GameBuild.Reporter { permille, step, done, total ->
publish(State.Working(task, permille.toLong(), 1000, step, done, total))
!cancelRequested
}
} ?: "The selected file could not be opened."
GameBuild.run(context, reporter, cancelled = { cancelRequested }, finishing)
} else {
uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor ->
when (task) {
Task.ExtractDisc -> DiscExtraction.run(context, descriptor.fd, displayName(context, uri), progress, finishing)
else -> GamePackageImport.run(context, descriptor, progress, finishing)
}
} ?: "The selected file could not be opened."
}
if (failure != null) {
publish(State.Failed(task, failure))
return
}
if (deleteSource && uri.scheme == "file") {
if (deleteSource && uri?.scheme == "file") {
uri.path?.let { File(it).delete() }
}
publish(State.Done(task))
@@ -17,8 +17,8 @@ import org.wiicompiled.quest.R
/**
* Runs a [GameSetup] task as a foreground service. Extracting a disc or importing a game takes
* minutes, and a panel app's process is otherwise fair game once the panel is closed; taking the
* headset off also sleeps the CPU, hence the partial wake lock.
* minutes and building one half an hour, and a panel app's process is otherwise fair game once the
* panel is closed; taking the headset off also sleeps the CPU, hence the partial wake lock.
*/
class GameSetupService : Service() {
@@ -41,7 +41,7 @@ class GameSetupService : Service() {
?: GameSetup.Task.ExtractDisc
startForeground(NOTIFICATION_ID, notification(GameSetup.State.Checking(task)), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
val uri = intent?.data
if (uri == null || !GameSetup.begin(task)) {
if ((uri == null && task != GameSetup.Task.BuildGame) || !GameSetup.begin(task)) {
// A task already going keeps the service, and ends it itself.
if (!GameSetup.isRunning) {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -50,16 +50,16 @@ class GameSetupService : Service() {
return START_NOT_STICKY
}
GameSetup.addListener(listener)
val deleteSource = intent.getBooleanExtra(EXTRA_DELETE_SOURCE, false)
val deleteSource = intent?.getBooleanExtra(EXTRA_DELETE_SOURCE, false) ?: false
val wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS)
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)
} finally {
releaseReadPermission(uri)
uri?.let(::releaseReadPermission)
if (wakeLock.isHeld) wakeLock.release()
mainExecutor.execute {
GameSetup.removeListener(listener)
@@ -88,17 +88,23 @@ class GameSetupService : Service() {
}
private fun notification(state: GameSetup.State): Notification {
val building = state is GameSetup.State.Checking && state.task == GameSetup.Task.BuildGame ||
state is GameSetup.State.Working && state.task == GameSetup.Task.BuildGame
val builder = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_wheel)
.setContentTitle(getString(R.string.disc_setup_notification_title))
.setContentTitle(getString(if (building) R.string.build_notification_title else R.string.disc_setup_notification_title))
.setOngoing(true)
.setOnlyAlertOnce(true)
if (state is GameSetup.State.Working) {
val percent = if (state.total > 0) (state.done * 100 / state.total).toInt() else 0
val text = if (state.task == GameSetup.Task.ImportPackage) R.string.disc_setup_importing else R.string.disc_setup_extracting
val text = when (state.task) {
GameSetup.Task.ImportPackage -> R.string.disc_setup_importing
GameSetup.Task.BuildGame -> R.string.disc_setup_building
GameSetup.Task.ExtractDisc -> R.string.disc_setup_extracting
}
builder.setContentText(getString(text, percent)).setProgress(100, percent, false)
} else {
builder.setContentText(getString(R.string.disc_setup_checking)).setProgress(0, 0, true)
builder.setContentText(getString(if (building) R.string.build_preparing else R.string.disc_setup_checking)).setProgress(0, 0, true)
}
return builder.build()
}
@@ -112,11 +118,18 @@ class GameSetupService : Service() {
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
/**
* 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 {
@@ -27,8 +27,8 @@ import org.wiicompiled.quest.R
*
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
* their disc on a PC and brought over with Import from computer). Home's main button is always
* the next of those steps, and Play once both are there.
* their disc on this headset, or on a PC and brought over with Import from computer). Home's main
* button is always the next of those steps, and Play once both are there.
*
* The game runs as [QuestActivity], an immersive activity in its own `:game` process. SDL and the
* runtime cannot start twice in one process, so that process ends with every game session, and
@@ -39,7 +39,7 @@ class LauncherActivity : Activity() {
private enum class Page { Home, Settings }
/** What Home's main and secondary buttons do. */
private enum class Action { Play, Resume, SelectDisc, ImportGame }
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame }
private lateinit var navHome: View
private lateinit var navSettings: View
@@ -111,6 +111,7 @@ class LauncherActivity : Activity() {
gameRunning = ::isGameRunning,
selectDiscImage = ::selectDiscImage,
importGame = ::importGame,
buildGame = ::buildGame,
)
savedInstanceState?.getString(KEY_TAB)?.let { name ->
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
@@ -124,6 +125,14 @@ class LauncherActivity : Activity() {
val restored = savedInstanceState?.getString(KEY_PAGE)?.let { name -> Page.entries.firstOrNull { it.name == name } }
showPage(restored ?: Page.Home)
// Unattended headset tests (docs/quest-port.md): debug builds start a build from adb.
if (BuildConfig.DEBUG && savedInstanceState == null && intent.getBooleanExtra(EXTRA_DEBUG_BUILD_GAME, false) &&
!GameSetup.isRunning && GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready
) {
Log.i(TAG, "Starting a game build requested over adb")
GameSetupService.startBuild(this)
}
}
override fun onResume() {
@@ -193,27 +202,42 @@ class LauncherActivity : Activity() {
val running = isGameRunning()
val setup = GameSetup.state
val settingUp = GameSetup.isRunning
val importing = setupTask(setup) == GameSetup.Task.ImportPackage
val task = setupTask(setup)
val importing = task == GameSetup.Task.ImportPackage
val building = task == GameSetup.Task.BuildGame
// Without anything yet, a player with only a headset starts from their disc image, and
// builds the game once its files are there; a PC-built game can always be imported instead.
mainAction = when {
running -> Action.Resume
gameStatus != GameLibrary.Status.Ready -> Action.ImportGame
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
gameStatus != GameLibrary.Status.Ready -> Action.BuildGame
else -> Action.Play
}
// Without anything yet, a player with only a headset starts from their disc image.
secondaryAction = when {
settingUp || running -> null
mainAction == Action.ImportGame && discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
mainAction == Action.SelectDisc -> Action.ImportGame
gameStatus != GameLibrary.Status.Ready || mainAction == Action.SelectDisc -> Action.ImportGame
else -> null
}
playButton.isEnabled = !settingUp
playIcon.setImageResource(if (!settingUp && (mainAction == Action.Play || mainAction == Action.Resume)) R.drawable.ic_play else R.drawable.ic_disc)
playText.text = when {
setup is GameSetup.State.Checking -> getString(if (importing) R.string.home_checking_package else R.string.home_checking)
setup is GameSetup.State.Working -> getString(if (importing) R.string.home_importing else R.string.home_extracting, percent(setup))
setup is GameSetup.State.Checking -> getString(
when {
building -> R.string.home_build_preparing
importing -> R.string.home_checking_package
else -> R.string.home_checking
},
)
setup is GameSetup.State.Working -> getString(
when {
building -> R.string.home_building
importing -> R.string.home_importing
else -> R.string.home_extracting
},
percent(setup),
)
setup is GameSetup.State.Finishing -> getString(R.string.home_finishing)
else -> getString(label(mainAction))
}
@@ -228,22 +252,31 @@ class LauncherActivity : Activity() {
cancel.visibility = if (settingUp && setup !is GameSetup.State.Finishing) View.VISIBLE else View.GONE
homeStatus.text = when {
setup is GameSetup.State.Working && building -> buildStatus(setup)
setup is GameSetup.State.Working -> getString(
R.string.home_extract_progress,
Formatter.formatShortFileSize(this, setup.done),
Formatter.formatShortFileSize(this, setup.total),
)
setup is GameSetup.State.Checking -> getString(if (importing) R.string.home_checking_package_status else R.string.home_checking_status)
setup is GameSetup.State.Checking -> getString(
when {
building -> R.string.build_step_prepare
importing -> R.string.home_checking_package_status
else -> R.string.home_checking_status
},
)
settingUp || setup is GameSetup.State.Failed -> ""
setup is GameSetup.State.Cancelled -> getString(R.string.home_setup_cancelled)
setup is GameSetup.State.Cancelled -> getString(if (building) R.string.home_build_cancelled else R.string.home_setup_cancelled)
running -> getString(R.string.home_running)
mainAction == Action.Play && setup is GameSetup.State.Done -> getString(R.string.home_setup_done)
mainAction == Action.Play && setup is GameSetup.State.Done -> getString(if (building) R.string.home_build_done else R.string.home_setup_done)
mainAction == Action.Play -> getString(R.string.home_put_on_headset)
else -> ""
}
when {
settingUp && building -> showBanner(getString(R.string.home_build_running), warning = false)
settingUp -> showBanner(null)
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)
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
@@ -265,6 +298,7 @@ class LauncherActivity : Activity() {
Action.Resume -> R.string.home_resume
Action.SelectDisc -> R.string.home_select_disc
Action.ImportGame -> R.string.home_import
Action.BuildGame -> R.string.home_build
}
private fun perform(action: Action) {
@@ -273,6 +307,26 @@ class LauncherActivity : Activity() {
Action.Play, Action.Resume -> play()
Action.SelectDisc -> selectDiscImage()
Action.ImportGame -> importGame()
Action.BuildGame -> buildGame()
}
}
private fun buildStatus(state: GameSetup.State.Working): String = when (state.step) {
GameBuild.Step.Download -> getString(R.string.build_step_download, state.stepDone, state.stepTotal)
GameBuild.Step.Translate -> getString(R.string.build_step_translate, state.stepDone, state.stepTotal)
GameBuild.Step.Compile -> getString(R.string.build_step_compile, state.stepDone, state.stepTotal)
GameBuild.Step.Link -> getString(R.string.build_step_link)
GameBuild.Step.Install -> getString(R.string.build_step_install)
GameBuild.Step.Prepare, null -> getString(R.string.build_step_prepare)
}
/** Builds the game on this headset from DATA, after saying what that takes. */
private fun buildGame() {
if (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
confirm(R.string.home_build_title, message, R.string.home_build) {
GameSetupService.startBuild(this)
showPage(Page.Home)
}
}
@@ -403,5 +457,6 @@ class LauncherActivity : Activity() {
const val REQUEST_GAME_PACKAGE = 2
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,126 @@
package org.wiicompiled.quest.launcher
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.Inflater
/**
* Reads a few entries of a large zip on a web server without downloading the rest: the central
* directory from the end of the file, then only the wanted entries, neighbouring ones in one
* request. [fetch] returns the bytes from start to endInclusive (an HTTP range request).
*
* Only what the Android NDK zip needs: no ZIP64, stored or deflated entries.
*/
class RemoteZip(private val size: Long, private val fetch: (start: Long, endInclusive: Long) -> ByteArray) {
/** [end] is where the next entry (or the central directory) starts, which bounds this one's bytes. */
data class Entry(val name: String, val method: Int, val compressedSize: Long, val size: Long, val offset: Long, val end: Long)
fun entries(): List<Entry> {
val tailStart = maxOf(0L, size - EOCD_SEARCH)
val tail = fetch(tailStart, size - 1)
var eocd = -1
for (i in tail.size - EOCD_SIZE downTo 0) {
if (u32(tail, i) == EOCD_SIGNATURE) { eocd = i; break }
}
if (eocd < 0) throw IOException("No end of central directory record")
val count = u16(tail, eocd + 10)
val directorySize = u32(tail, eocd + 12)
val directoryOffset = u32(tail, eocd + 16)
if (count == 0xFFFF || directoryOffset == 0xFFFFFFFFL) throw IOException("ZIP64 archives are not supported")
val directory = fetch(directoryOffset, directoryOffset + directorySize - 1)
data class Raw(val name: String, val method: Int, val compressedSize: Long, val size: Long, val offset: Long)
val raw = ArrayList<Raw>(count)
var at = 0
repeat(count) {
if (u32(directory, at) != CENTRAL_SIGNATURE) throw IOException("Corrupt central directory")
val nameLength = u16(directory, at + 28)
val extraLength = u16(directory, at + 30)
val commentLength = u16(directory, at + 32)
raw += Raw(
name = String(directory, at + 46, nameLength, Charsets.UTF_8),
method = u16(directory, at + 10),
compressedSize = u32(directory, at + 20),
size = u32(directory, at + 24),
offset = u32(directory, at + 42),
)
at += 46 + nameLength + extraLength + commentLength
}
val sorted = raw.sortedBy { it.offset }
return sorted.mapIndexed { i, entry ->
val end = if (i + 1 < sorted.size) sorted[i + 1].offset else directoryOffset
Entry(entry.name, entry.method, entry.compressedSize, entry.size, entry.offset, end)
}
}
/**
* Calls [onEntry] with each of [wanted]'s contents, in offset order. Entries less than
* [maxGap] apart are fetched in one request of at most [maxRequest] bytes.
*/
fun read(wanted: List<Entry>, maxGap: Long = 1L shl 20, maxRequest: Long = 16L shl 20, onEntry: (Entry, ByteArray) -> Unit) {
val entries = wanted.sortedBy { it.offset }
var first = 0
while (first < entries.size) {
var last = first
while (last + 1 < entries.size &&
entries[last + 1].offset - entries[last].end < maxGap &&
entries[last + 1].end - entries[first].offset <= maxRequest
) {
last++
}
val start = entries[first].offset
val chunk = fetch(start, entries[last].end - 1)
for (i in first..last) {
val entry = entries[i]
val local = (entry.offset - start).toInt()
if (u32(chunk, local) != LOCAL_SIGNATURE) throw IOException("Corrupt entry ${entry.name}")
val dataStart = local + 30 + u16(chunk, local + 26) + u16(chunk, local + 28)
onEntry(entry, content(entry, chunk, dataStart))
}
first = last + 1
}
}
private fun content(entry: Entry, chunk: ByteArray, dataStart: Int): ByteArray {
if (dataStart + entry.compressedSize > chunk.size) throw IOException("Truncated entry ${entry.name}")
val bytes = when (entry.method) {
0 -> chunk.copyOfRange(dataStart, dataStart + entry.compressedSize.toInt())
8 -> {
val inflater = Inflater(true)
try {
inflater.setInput(chunk, dataStart, entry.compressedSize.toInt())
val out = ByteArrayOutputStream(entry.size.toInt())
val buffer = ByteArray(64 * 1024)
while (!inflater.finished()) {
val n = inflater.inflate(buffer)
if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) break
out.write(buffer, 0, n)
}
out.toByteArray()
} finally {
inflater.end()
}
}
else -> throw IOException("Entry ${entry.name} uses compression method ${entry.method}")
}
if (bytes.size.toLong() != entry.size) throw IOException("Entry ${entry.name} has the wrong size")
return bytes
}
private fun u16(bytes: ByteArray, at: Int): Int =
ByteBuffer.wrap(bytes, at, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
private fun u32(bytes: ByteArray, at: Int): Long =
ByteBuffer.wrap(bytes, at, 4).order(ByteOrder.LITTLE_ENDIAN).int.toLong() and 0xFFFFFFFFL
private companion object {
const val EOCD_SIGNATURE = 0x06054b50L
const val CENTRAL_SIGNATURE = 0x02014b50L
const val LOCAL_SIGNATURE = 0x04034b50L
const val EOCD_SIZE = 22
const val EOCD_SEARCH = EOCD_SIZE + 0xFFFFL
}
}
@@ -38,6 +38,7 @@ class SettingsPage(
private val gameRunning: () -> Boolean,
private val selectDiscImage: () -> Unit,
private val importGame: () -> Unit,
private val buildGame: () -> Unit,
) {
enum class Tab(val label: Int) {
@@ -281,6 +282,12 @@ class SettingsPage(
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
}
info(R.string.about_game, gameText, stacked = true)
action(
R.string.home_build, R.string.about_build_helper, R.string.home_build,
enabled = !GameSetup.isRunning && GameStorage.discStatus(activity) == GameStorage.DiscStatus.Ready,
) {
buildGame()
}
action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) {
importGame()
}
@@ -0,0 +1,93 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.io.InterruptedIOException
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* Runs a build tool from the app's private storage. Android refuses to exec files there, but lets
* the system linker load them (the same permission that lets the game load libmain.so), so every
* tool starts as `/system/bin/linker64 <absolute path> <arguments>`.
*
* Output goes to [log] and to the caller line by line. [cancelled] is polled while the tool runs;
* when it turns true the tool is stopped and [run] throws [InterruptedIOException].
*/
class ToolProcess(
private val environment: Map<String, String>,
private val log: BuildLog,
private val cancelled: () -> Boolean,
) {
class Result(val exitCode: Int, val tail: List<String>) {
/** Android's low memory killer ends a process with SIGKILL, which reads as 128 + 9. */
val killed: Boolean get() = exitCode == 137
}
fun run(workingDirectory: File, executable: File, arguments: List<String>, onLine: (String) -> Unit = {}): Result {
val command = listOf(LINKER, executable.absolutePath) + arguments
val process = ProcessBuilder(command)
.directory(workingDirectory)
.redirectErrorStream(true)
.apply { environment().putAll(this@ToolProcess.environment) }
.start()
val tail = ArrayDeque<String>()
val reader = thread(name = "BuildOutput") {
process.inputStream.bufferedReader().useLines { lines ->
for (line in lines) {
log.line(line)
synchronized(tail) {
tail.addLast(line)
if (tail.size > TAIL_LINES) tail.removeFirst()
}
onLine(line)
}
}
}
try {
while (!process.waitFor(POLL_MS, TimeUnit.MILLISECONDS)) {
if (cancelled()) {
stop(process)
throw InterruptedIOException("Build cancelled")
}
}
} catch (e: InterruptedException) {
stop(process)
throw InterruptedIOException("Build interrupted")
}
reader.join(READER_JOIN_MS)
return Result(process.exitValue(), synchronized(tail) { tail.toList() })
}
private fun stop(process: Process) {
process.destroy()
if (!process.waitFor(STOP_GRACE_MS, TimeUnit.MILLISECONDS)) process.destroyForcibly()
}
private companion object {
const val LINKER = "/system/bin/linker64"
const val POLL_MS = 250L
const val STOP_GRACE_MS = 2000L
const val READER_JOIN_MS = 5000L
const val TAIL_LINES = 40
}
}
/** The build's log file, which several tools write to at once. */
class BuildLog(val file: File) {
init {
file.parentFile?.mkdirs()
}
private val writer = file.bufferedWriter()
@Synchronized
fun line(text: String) {
writer.write(text)
writer.newLine()
writer.flush()
}
@Synchronized
fun close() = writer.close()
}
+23 -3
View File
@@ -21,9 +21,25 @@
<string name="home_put_on_headset">The game opens in the headset.</string>
<string name="home_data_missing">The game is ready, but its game files (DATA) are missing. Select your own PAL (RMCP01) disc image to extract them, or import a game file from your computer that includes them.\n\nThe game files go to:\n%1$s</string>
<string name="home_import">Import from computer</string>
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, and press Select disc image.</string>
<string name="home_game_missing">The game files are ready. The game itself is built from your disc: in the WiiCompiled VR launcher on your computer, use Build for Quest, copy the .wcgame file to this headset, then press Import from computer.</string>
<string name="home_game_stale">This app was updated, so the game must be built again for it: use Build for Quest in the WiiCompiled VR launcher again, then Import from computer.</string>
<string name="home_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_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_build">Build on this Quest</string>
<string name="home_build_preparing">Preparing…</string>
<string name="home_building">Building %1$d%%</string>
<string name="home_build_title">Build the game on this Quest?</string>
<string name="home_build_message">The game is built on this headset from your own disc files. It takes about 30–40 minutes on a Quest 3 and uses most of its memory, so close other apps and keep the headset on its charger.\n\nThe first build downloads about 10 MB of Android build files from Google.</string>
<string name="home_build_replace_message">The installed game stays until the new build has finished.\n\nBuilding takes about 30–40 minutes on a Quest 3 and uses most of its memory, so close other apps and keep the headset on its charger.</string>
<string name="home_build_running">Building the game from your disc files: about 30–40 minutes on a Quest 3. Keep the headset on its charger. The build carries on if you close this panel.</string>
<string name="home_build_done">The game is built.</string>
<string name="home_build_cancelled">Build cancelled. Building again picks up where it stopped.</string>
<string name="home_build_failed">The game could not be built.\n%1$s</string>
<string name="build_step_prepare">Unpacking the build tools</string>
<string name="build_step_download">Downloading Android build files (%1$d of %2$d MB)</string>
<string name="build_step_translate">Translating the game (step %1$d of %2$d)</string>
<string name="build_step_compile">Compiling %1$d of %2$d</string>
<string name="build_step_link">Linking the game</string>
<string name="build_step_install">Installing the game</string>
<string name="home_checking_package">Checking game file…</string>
<string name="home_checking_package_status">Checking the game file was built from your disc for this version of the app.</string>
<string name="home_importing">Importing %1$d%%</string>
@@ -159,6 +175,7 @@
<string name="about_game_ready">Built by %1$s on %2$s</string>
<string name="about_game_missing">Not installed yet</string>
<string name="about_game_stale">Built for another version of this app; build it again</string>
<string name="about_build_helper">Builds the game from DATA on this headset: about 30–40 minutes on a Quest 3.</string>
<string name="about_import_helper">Installs a .wcgame made by Build for Quest in the WiiCompiled VR launcher on a computer.</string>
<string name="section_about_diagnostics">Diagnostics</string>
<string name="about_openxr_logging">OpenXR diagnostic logging</string>
@@ -169,6 +186,9 @@
<string name="disc_setup_notification_title">Setting up the game files</string>
<string name="disc_setup_checking">Checking the disc image…</string>
<string name="disc_setup_extracting">Extracting: %1$d%%</string>
<string name="disc_setup_building">Building: %1$d%%</string>
<string name="build_notification_title">Building the game</string>
<string name="build_preparing">Preparing the build…</string>
<string name="disc_setup_importing">Importing: %1$d%%</string>
<string name="game_not_installed">The game is not installed. Open WiiCompiled VR and set it up from Home.</string>
</resources>
@@ -0,0 +1,97 @@
package org.wiicompiled.quest.launcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
/** The headset must turn the game kit's recipe into the same commands android/QuestGameKit.psm1 runs. */
class BuildRecipeTest {
@Test
fun quotesResponseFileArgumentsForGnuTokenizers() {
assertEquals("-DIMGUI_USER_CONFIG=\\\"aurora/imgui_config.h\\\"", BuildRecipe.responseFileArgument("-DIMGUI_USER_CONFIG=\"aurora/imgui_config.h\""))
assertEquals("/data/with\\ space/a\\\\b", BuildRecipe.responseFileArgument("/data/with space/a\\b"))
assertEquals("-O2\n-c\n", BuildRecipe.responseFile(listOf("-O2", "-c")))
}
@Test
fun expandsPlaceholdersAndRejectsUnknownOnes() {
val values = mapOf("kit" to "/k", "sysroot" to "/n/sysroot")
assertEquals("-I/k/include", BuildRecipe.expand("-I{kit}/include", values))
assertEquals("--sysroot=/n/sysroot", BuildRecipe.expand("--sysroot={sysroot}", values))
assertThrows(IllegalArgumentException::class.java) { BuildRecipe.expand("-I{workspace}", values) }
}
@Test
fun expandsTheLinkSlotsInPlace() {
val template = listOf("-o", "{output}", "{kit}/objects/001_a.o", "{game:runtime}", "--start-lib", "{game:translated}", "--end-lib", "-lm")
val arguments = BuildRecipe.linkArguments(
template,
mapOf("kit" to "/k", "output" to "/w/libmain.so"),
mapOf("runtime" to listOf("/o/data.o", "/o/blob.o"), "translated" to listOf("/o/s1.o", "/o/s2.o")),
)
assertEquals(
listOf("-o", "/w/libmain.so", "/k/objects/001_a.o", "/o/data.o", "/o/blob.o", "--start-lib", "/o/s1.o", "/o/s2.o", "--end-lib", "-lm"),
arguments,
)
assertThrows(IllegalArgumentException::class.java) {
BuildRecipe.linkArguments(listOf("{game:product}"), emptyMap(), emptyMap())
}
}
@Test
fun readsSourceListsFromShardsCmake() {
val shards = """
set(MKW_TRANSLATED_SHARD_ROOT "/w/generated/build_shards")
set(MKW_BASE_COMMON_SHARDS
"/w/generated/build_shards/base_common/shard_1.cpp"
"/w/generated/build_shards/base_common/shard_2.cpp"
)
set(MKW_BASE_PORTABLE_SENSITIVE_SHARDS
)
set(MKW_BASE_REGISTRATION_SOURCES
"C:\w\generated\build_shards\base_registration\registration_00.cpp"
)
""".trimIndent()
assertEquals(
listOf("/w/generated/build_shards/base_common/shard_1.cpp", "/w/generated/build_shards/base_common/shard_2.cpp"),
BuildRecipe.sourceList(shards, "MKW_BASE_COMMON_SHARDS"),
)
assertEquals(emptyList<String>(), BuildRecipe.sourceList(shards, "MKW_BASE_PORTABLE_SENSITIVE_SHARDS"))
assertEquals(emptyList<String>(), BuildRecipe.sourceList(shards, "MKW_RETRO_MOD_SHARDS"))
assertEquals(listOf("C:/w/generated/build_shards/base_registration/registration_00.cpp"), BuildRecipe.sourceList(shards, BuildRecipe.REGISTRATION_LIST))
}
@Test
fun readsTheEntryPointFromTheGameManifest() {
val manifest = "translation:\n entry_points:\n - 0x800060A4\n function_map:\n path: MAP.txt\n"
assertEquals("0x800060A4", BuildRecipe.entryPoint(manifest))
assertThrows(IllegalArgumentException::class.java) { BuildRecipe.entryPoint("translation:\n") }
}
@Test
fun rewritesWindowsBlobSectionsForElfOnly() {
val windows = "// blobs\n.section .rdata,\"dr\"\n.incbin \"a.bin\"\n"
assertEquals(
"// blobs\n.section .rodata,\"a\",@progbits\n.incbin \"a.bin\"\n\n.section .note.GNU-stack,\"\",@progbits\n",
BuildRecipe.elfBlobAssembly(windows),
)
val elf = "// blobs\n.section .rodata,\"a\",@progbits\n"
assertEquals(elf, BuildRecipe.elfBlobAssembly(elf))
}
@Test
fun namesObjectsAfterTheSourceStem() {
assertEquals("shard_8b98.o", BuildRecipe.objectName("/w/generated/build_shards/base_common/shard_8b98.cpp"))
assertEquals("data_sections_init_blobs_android.o", BuildRecipe.objectName("/w/data_sections_init_blobs_android.S"))
}
@Test
fun digestsTheFileListLikePrepareQuestToolchain() {
// SHA-256 of "a/x.h 11\nb.so 22", the lines sorted by path and joined with \n.
assertEquals(
java.security.MessageDigest.getInstance("SHA-256").digest("a/x.h 11\nb.so 22".toByteArray()).joinToString("") { "%02x".format(it) },
BuildRecipe.listDigest(mapOf("b.so" to "22", "a/x.h" to "11")),
)
}
}
@@ -0,0 +1,77 @@
package org.wiicompiled.quest.launcher
import java.io.ByteArrayOutputStream
import java.util.zip.CRC32
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** The NDK download reads only the wanted entries of a zip, through range requests. */
class RemoteZipTest {
private val contents = linkedMapOf(
"ndk/sysroot/usr/include/stdio.h" to "int printf(const char *, ...);\n".repeat(200).toByteArray(),
"ndk/sysroot/usr/lib/aarch64-linux-android/29/libc.so" to ByteArray(70_000) { (it * 31).toByte() },
"ndk/prebuilt/bin/clang" to ByteArray(300_000) { (it * 7).toByte() },
"ndk/lib/clang/21/lib/linux/libclang_rt.builtins-aarch64-android.a" to "!<arch>\n".toByteArray(),
)
private val zip: ByteArray = ByteArrayOutputStream().also { bytes ->
ZipOutputStream(bytes).use { out ->
for ((name, data) in contents) {
val entry = ZipEntry(name)
if (name.endsWith(".a")) {
// A stored entry: sizes and CRC must be known up front.
entry.method = ZipEntry.STORED
entry.size = data.size.toLong()
entry.crc = CRC32().apply { update(data) }.value
}
out.putNextEntry(entry)
out.write(data)
out.closeEntry()
}
out.putNextEntry(ZipEntry("ndk/empty-dir/"))
out.closeEntry()
}
}.toByteArray()
@Test
fun readsWantedEntriesWithoutTheRest() {
val requests = mutableListOf<LongRange>()
val remote = RemoteZip(zip.size.toLong()) { start, end ->
requests += start..end
zip.copyOfRange(start.toInt(), end.toInt() + 1)
}
val entries = remote.entries()
assertEquals(contents.keys + "ndk/empty-dir/", entries.map { it.name }.toSet())
val wanted = entries.filter { it.name.contains("sysroot") || it.name.endsWith(".a") }
val read = LinkedHashMap<String, ByteArray>()
requests.clear()
remote.read(wanted, maxGap = 64) { entry, bytes -> read[entry.name] = bytes }
assertEquals(wanted.map { it.name }.toSet(), read.keys)
for ((name, bytes) in read) assertArrayEquals(name, contents.getValue(name), bytes)
// The two sysroot entries sit next to each other and share a request; clang, 300 KB, is skipped.
assertEquals(2, requests.size)
assertTrue(requests.sumOf { it.last - it.first + 1 } < 150_000)
}
@Test
fun keepsRequestsWithinTheSizeLimit() {
val requests = mutableListOf<LongRange>()
val remote = RemoteZip(zip.size.toLong()) { start, end ->
requests += start..end
zip.copyOfRange(start.toInt(), end.toInt() + 1)
}
val files = remote.entries().filter { !it.name.endsWith("/") }
requests.clear()
var count = 0
remote.read(files, maxGap = 1L shl 20, maxRequest = 1) { _, _ -> count++ }
assertEquals(files.size, count)
assertEquals(files.size, requests.size)
}
}