mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 06:00:25 +02:00
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
393 lines
18 KiB
Kotlin
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")
|
|
}
|