Files
mitch030504--Wiicompiled_VR…/android/app/build.gradle.kts
T
Claude 150062280d Add a Steam Frame flavour of the Android app with Frame controller bindings
The Steam Frame runs Android apps through Lepton with SteamVR's OpenXR
runtime. A third headset flavour, steamFrame, targets it:

- -mcpu=cortex-x4+nosve for the Snapdragon 8 Gen 3 (its firmware does
  not expose SVE, which clang otherwise auto-vectorises with), from one
  flavour-to-CPU map that the kit export now reads instead of guessing
  from the variant name.
- MKW_ANDROID_HEADSET=steam_frame defines MKW_HEADSET_STEAM_FRAME for
  the runtime's own targets, for Frame-specific defaults.
- A manifest without the Horizon OS entries, and FrameEntryActivity as
  the single real MAIN/LAUNCHER activity with the Khronos and Oculus VR
  categories, which Lepton needs to start an app in VR. It opens the
  setup panel and, when the selected game can start, the game on top.
- XR_VALVE_frame_controller_interaction: the Frame controller profile
  with its left D-pad (new dpad_* actions, the Wii Remote's D-pad or the
  gamepad's), View as menu and the left shoulder as the panel button.
  Also requested on Windows for SteamVR streaming to a Frame.
- Build-Quest.ps1, Build-QuestGame.ps1 and Run-Quest.ps1 take
  -Headset frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HBRGKTE1GnN2ah8gcZKr3
2026-10-04 08:27:12 +00:00

393 lines
18 KiB
Kotlin

import javax.inject.Inject
import org.gradle.api.provider.Property
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// Translator output for the disc the user owns (data_sections_init.cpp,
// RuntimeConfig.h, build_shards/shards.cmake). Never checked in.
val mkwGeneratedDir = providers.gradleProperty("mkwGeneratedDir").orNull
// Optional: a directory of already-fetched dependency sources (the installer's
// BuildWorkspace/Dependencies) so the native configure does not download them.
val mkwDependenciesDir = providers.gradleProperty("mkwDependenciesDir").orNull
// Optional: the Dawn package android/Build-QuestDawn.ps1 built with Aurora's patches (fragment density
// maps for foveated rendering). Without it the stock prebuilt Dawn is used and foveation is off.
val mkwQuestDawnDir = providers.gradleProperty("mkwQuestDawnDir").orNull
// Optional: a directory holding a second SDL3 AAR/prefab is not needed; the
// AAR in app/libs is produced by Prepare-QuestDependencies.ps1.
val mkwRepoRoot = rootProject.file("..").canonicalFile
// The runtime's read-only assets travel inside the APK and are unpacked by
// QuestActivity into the app's private storage on first launch.
val runtimeResources = layout.buildDirectory.dir("generated/assets/runtimeResources")
val prepareRuntimeResources by tasks.registering(Copy::class) {
val assets = File(mkwRepoRoot, "runtime/assets")
from(File(assets, "wii")) { into("runtime_resources/wii_bootstrap") }
from(File(assets, "dsp/dsp_coef.bin")) { into("runtime_resources") }
from(File(assets, "pipeline/initial_pipeline_cache.db")) { into("runtime_resources") }
into(runtimeResources)
}
// The disc the launcher accepts when it extracts the player's image: the game ID and
// the clean PAL main.dol / StaticR.rel hashes, read from the same recomp.yml pins the
// PC installer's payload manifest uses (Launcher/Build-Installer.ps1).
val discPins: Map<String, String> = run {
val manifest = File(mkwRepoRoot, "projects/mkwii/recomp.yml").readText()
fun pin(pattern: String): String =
Regex(pattern, RegexOption.DOT_MATCHES_ALL).find(manifest)?.groupValues?.get(1)
?: throw GradleException("projects/mkwii/recomp.yml has no match for $pattern")
mapOf(
"DISC_GAME_ID" to pin("""\n\s*game_id:\s*(\w+)"""),
"DISC_DOL_SHA256" to pin("""\n\s*dol:.*?sha256:\s*([0-9a-fA-F]{64})"""),
"DISC_REL_SHA256" to pin("""\n\s*rel:.*?sha256:\s*([0-9a-fA-F]{64})"""),
)
}
// 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)")
}
// Each headset flavour's AArch64 -mcpu target. The game kit records it, and the kit export, the APK
// audit (Build-Quest.ps1), the PC build (QuestGameKit.psm1), the on-headset build and the package
// import all refuse a game built for another one.
val headsetCpus = mapOf(
// Snapdragon XR2 and newer. Keep this as the default build target.
"modernQuest" to "cortex-a77",
// Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1.
"quest1" to "kryo",
// Snapdragon 8 Gen 3 (Cortex-X4, A720 and A520, all ARMv9.2). Its firmware does not expose SVE,
// which clang would otherwise auto-vectorise with for a cortex-x4.
"steamFrame" to "cortex-x4+nosve",
)
// 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).
val nodJniDir = rootProject.file("nod-jni")
val nodJniTargetDir = layout.buildDirectory.dir("nod-jni")
val nodJniLibs = layout.buildDirectory.dir("generated/jniLibs/nodJni")
val buildNodJni by tasks.registering(Exec::class) {
inputs.dir(File(nodJniDir, "src"))
inputs.files(File(nodJniDir, "Cargo.toml"), File(nodJniDir, "Cargo.lock"))
outputs.file(nodJniTargetDir.map { it.file("aarch64-linux-android/release/libnod_jni.so") })
workingDir = nodJniDir
val windows = System.getProperty("os.name").startsWith("Windows")
val cargoHome = System.getenv("CARGO_HOME")?.let(::File) ?: File(System.getProperty("user.home"), ".cargo")
val cargo = providers.gradleProperty("cargo").orNull
?: File(cargoHome, if (windows) "bin/cargo.exe" else "bin/cargo").takeIf { it.isFile }?.path
?: "cargo"
commandLine(cargo, "build", "--release", "--locked", "--target", "aarch64-linux-android")
doFirst {
val host = when {
windows -> "windows-x86_64"
System.getProperty("os.name").startsWith("Mac") -> "darwin-x86_64"
else -> "linux-x86_64"
}
val bin = File(androidComponents.sdkComponents.ndkDirectory.get().asFile, "toolchains/llvm/prebuilt/$host/bin")
val clang = File(bin, "aarch64-linux-android29-clang" + if (windows) ".cmd" else "").path
environment("CARGO_TARGET_DIR", nodJniTargetDir.get().asFile.path)
environment("CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER", clang)
environment("CC_aarch64_linux_android", clang)
environment("AR_aarch64_linux_android", File(bin, "llvm-ar" + if (windows) ".exe" else "").path)
}
}
val stageNodJni by tasks.registering(Copy::class) {
from(buildNodJni) { include("**/libnod_jni.so") }
eachFile { path = "arm64-v8a/$name" }
includeEmptyDirs = false
into(nodJniLibs)
}
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
dependsOn(stageNodJni)
}
android {
namespace = "org.wiicompiled.quest"
compileSdk = 36
ndkVersion = "29.0.14206865"
defaultConfig {
applicationId = "org.wiicompiled.quest"
// Quest 2 ships Android 10 (API 29); AHardwareBuffer/Vulkan 1.1 need 26+.
minSdk = 29
targetSdk = 34
versionCode = 6
versionName = "0.6.0-quest"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
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",
"-DMKW_REPO_ROOT=${mkwRepoRoot.path.replace('\\', '/')}",
)
if (mkwGeneratedDir != null) {
arguments += "-DMKW_GENERATED_DIR=${File(mkwGeneratedDir).canonicalPath.replace('\\', '/')}"
}
if (mkwDependenciesDir != null) {
arguments += "-DMKW_DEPENDENCIES_DIR=${File(mkwDependenciesDir).canonicalPath.replace('\\', '/')}"
}
// Always passed, empty when absent, so the native build drops a previous override.
arguments += "-DMKW_QUEST_DAWN_PACKAGE_DIR=" +
(mkwQuestDawnDir?.let { File(it).canonicalPath.replace('\\', '/') } ?: "")
}
}
}
flavorDimensions += "headset"
productFlavors {
create("modernQuest") {
dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest2|quest3|quest3s|questpro"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false")
buildConfigField("boolean", "STEAM_FRAME", "false")
buildConfigField("String", "ANDROID_CPU", "\"${headsetCpus.getValue("modernQuest")}\"")
externalNativeBuild {
cmake {
arguments += "-DMKW_ANDROID_CPU=${headsetCpus.getValue("modernQuest")}"
}
}
}
create("quest1") {
dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest|quest2"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "true")
buildConfigField("boolean", "STEAM_FRAME", "false")
buildConfigField("String", "ANDROID_CPU", "\"${headsetCpus.getValue("quest1")}\"")
externalNativeBuild {
cmake {
arguments += "-DMKW_ANDROID_CPU=${headsetCpus.getValue("quest1")}"
}
}
}
// Valve's Steam Frame, through Lepton (SteamOS's Android layer) and SteamVR's Android
// OpenXR runtime. Its manifest (src/steamFrame) drops the Horizon OS entries, and
// MKW_ANDROID_HEADSET gives the runtime the Frame's defaults (runtime_config.h).
create("steamFrame") {
dimension = "headset"
// Horizon OS only; src/steamFrame/AndroidManifest.xml removes the entry.
manifestPlaceholders["mkwQuestSupportedDevices"] = ""
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false")
buildConfigField("boolean", "STEAM_FRAME", "true")
buildConfigField("String", "ANDROID_CPU", "\"${headsetCpus.getValue("steamFrame")}\"")
externalNativeBuild {
cmake {
arguments += listOf(
"-DMKW_ANDROID_CPU=${headsetCpus.getValue("steamFrame")}",
"-DMKW_ANDROID_HEADSET=steam_frame",
)
}
}
}
}
externalNativeBuild {
cmake {
// aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1;
// Build-Quest.ps1 points cmake.dir in local.properties at a system CMake.
path = file("src/main/cpp/CMakeLists.txt")
}
}
buildFeatures {
prefab = true
buildConfig = true
}
sourceSets.named("main") {
assets.srcDir(runtimeResources)
jniLibs.srcDir(nodJniLibs)
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
jniLibs {
useLegacyPackaging = false
// Built only to produce each flavour's game kit; their objects travel as assets/game_kit.
excludes += "**/libmkw_quest_kit_probe*.so"
}
}
lint {
disable += setOf("ChromeOsAbiSupport", "DiscouragedApi")
}
}
tasks.named("preBuild") {
dependsOn(prepareRuntimeResources)
}
/**
* Exports the game kit (android/QuestGameKit.psm1) from the CMake tree this variant's native
* build just produced, into the variant's assets as game_kit/. The tree is found through the
* probe library: AGP gives the .cxx directory and the obj directory the same configuration hash.
*/
abstract class ExportQuestGameKit : DefaultTask() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@get:Internal
abstract val appDir: DirectoryProperty
@get:Internal
abstract val script: RegularFileProperty
@get:Internal
abstract val llvmStrip: RegularFileProperty
@get:Input
abstract val androidCpu: Property<String>
@get:Inject
abstract val execOperations: ExecOperations
init {
// The kit's inputs are CMake's outputs, which Gradle does not track; exporting takes seconds.
outputs.upToDateWhen { false }
}
@TaskAction
fun export() {
val app = appDir.get().asFile
data class Candidate(val probe: File, val binaryDir: File, val cpu: String?)
fun cacheValue(cache: String, name: String): String? =
Regex("(?m)^${Regex.escape(name)}(?::[^=\\r\\n]*)?=([^\\r\\n]*)$")
.find(cache)?.groupValues?.get(1)?.trim()
val candidates = File(app, "build/intermediates/cxx").walkTopDown()
.filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
.map { probe ->
val configuration = probe.parentFile.parentFile.parentFile // <Variant>/<hash>
val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a")
val cacheFile = File(binaryDir, "CMakeCache.txt")
val cache = cacheFile.takeIf { it.isFile }?.readText().orEmpty()
Candidate(probe, binaryDir, cacheValue(cache, "MKW_ANDROID_CPU"))
}
.toList()
val expectedCpu = androidCpu.get()
val matching = candidates.filter {
it.cpu == expectedCpu && File(it.binaryDir, "build.ninja").isFile
}
val chosen = matching.maxByOrNull { it.probe.lastModified() }
?: throw GradleException(
"No CMake tree for Android CPU $expectedCpu. Found: " +
candidates.joinToString { "${it.binaryDir} (cpu=${it.cpu})" }
)
val binaryDir = chosen.binaryDir
val kitDir = File(outputDir.get().asFile, "game_kit")
execOperations.exec {
commandLine(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
"-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path,
"-AndroidCpu", expectedCpu,
)
}
}
}
/**
* The toolchain for building the game on the headset (android/Prepare-QuestToolchain.ps1): the
* translator for Android, clang/lld, and the pin of the NDK files the headset downloads. Packaged
* into the base variants' assets as quest_toolchain/.
*/
abstract class PrepareQuestToolchain : DefaultTask() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sources: ConfigurableFileCollection
@get:Internal
abstract val script: RegularFileProperty
@get:Internal
abstract val ndkLlvm: DirectoryProperty
@get:Inject
abstract val execOperations: ExecOperations
@TaskAction
fun prepare() {
execOperations.exec {
commandLine(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
"-OutputDir", File(outputDir.get().asFile, "quest_toolchain").path, "-NdkLlvm", ndkLlvm.get().asFile.path,
)
}
}
}
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() }
// 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"))
androidCpu.set(headsetCpus.getValue(checkNotNull(variant.flavorName) { "${variant.name} has no headset flavour" }))
outputDir.set(layout.buildDirectory.dir("generated/assets/questGameKit/${variant.name}"))
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)
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)
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
// SDLActivity and libSDL3.so; the AAR is downloaded by Prepare-QuestDependencies.ps1.
implementation(files("libs/SDL3-3.4.4.aar"))
testImplementation("junit:junit:4.13.2")
// android.jar only stubs org.json; the GameBanana parsing tests need a real one.
testImplementation("org.json:json:20180813")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
}