mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 07:00:30 +02:00
Added Android Launcher and Disk Extration
This commit is contained in:
1 parent
1346e53cd6
commit
2f85207c39
74 files changed
+6153
-70
No files matched your search
+13
-2
@@ -87,10 +87,21 @@ $apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1
|
||||
if (-not $apk) { throw "No APK under $apkDir" }
|
||||
Write-Host "APK: $($apk.FullName)"
|
||||
|
||||
# The base APK must be distributable: no translated game inside, only the game kit.
|
||||
if ($Flavor -eq 'base') {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
|
||||
try {
|
||||
$gameLibraries = @($archive.Entries | Where-Object { $_.FullName -match '^lib/[^/]+/libmain[^/]*\.so$' })
|
||||
$hasKit = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/game_kit/kit.json' }).Count -gt 0
|
||||
} finally { $archive.Dispose() }
|
||||
if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" }
|
||||
if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' }
|
||||
}
|
||||
|
||||
if ($Install) {
|
||||
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
|
||||
& $adb install -r $apk.FullName
|
||||
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
|
||||
Write-Host 'Installed. Push the extracted disc with:'
|
||||
Write-Host ' adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA'
|
||||
Write-Host 'Installed. Build and push the game with android/Build-QuestGame.ps1 -Install.'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# Builds the player's Quest game on a PC: compiles the translator's output for their own disc
|
||||
# against the Quest game kit (see QuestGameKit.psm1) and packages libmain.so as a .wcgame the
|
||||
# headset imports ("Import from computer" in the launcher).
|
||||
#
|
||||
# Two callers. A developer runs it from the checkout, where the toolchain defaults to the Android
|
||||
# SDK's NDK and ninja:
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 [-Generated <dir>] [-Kit <dir>]
|
||||
# [-Data <extracted disc dir>] [-Output <file.wcgame>] [-Install]
|
||||
#
|
||||
# WiiCompiled Setup's --build-quest runs the copy staged in an installation's BuildWorkspace\android,
|
||||
# naming everything explicitly: the kit it extracted from the Quest app, the NDK toolchain it
|
||||
# downloaded, the toolkit's ninja and the workspace's recomp.yml.
|
||||
#
|
||||
# -Generated is the translator output (the dev workspace's generated/ by default). -Kit is the game
|
||||
# kit the Quest app carries; it defaults to the one the last APK build exported. -Data adds the
|
||||
# extracted disc (the directory holding sys/ and files/) so the headset needs nothing else.
|
||||
# -Install pushes the package into the app's Import folder over adb, where the launcher picks it
|
||||
# up the next time it opens.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Generated = '',
|
||||
[string]$Kit = '',
|
||||
[string]$Output = '',
|
||||
[string]$Data = '',
|
||||
[string]$BuildDir = '',
|
||||
[string]$Manifest = '',
|
||||
[string]$ClangCxx = '',
|
||||
[string]$ClangC = '',
|
||||
[string]$Sysroot = '',
|
||||
[string]$Ninja = '',
|
||||
[string]$BuiltBy = 'android/Build-QuestGame.ps1',
|
||||
[int]$TranslatedJobs = 0,
|
||||
[switch]$Install
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force
|
||||
|
||||
$root = $PSScriptRoot
|
||||
$workspace = (Resolve-Path (Join-Path $root '..')).Path
|
||||
if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' }
|
||||
if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' }
|
||||
if (-not $BuildDir) { $BuildDir = Join-Path $root 'app\build\questGame' }
|
||||
if (-not $Kit) {
|
||||
# The kit the last APK build packaged (the exportBase*QuestGameKit tasks), so the game
|
||||
# matches the app it will be imported into.
|
||||
$Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Directory.Name -eq 'game_kit' } |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName }
|
||||
}
|
||||
if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) { throw 'No game kit; run Build-Quest.ps1 first or pass -Kit' }
|
||||
if (-not (Test-Path (Join-Path $Generated 'build_shards\shards.cmake'))) { throw "No translated graph at $Generated" }
|
||||
|
||||
$sdkRoot = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' }
|
||||
if (-not $ClangCxx -or -not $ClangC -or -not $Sysroot) {
|
||||
$llvm = Join-Path $sdkRoot 'ndk\29.0.14206865\toolchains\llvm\prebuilt\windows-x86_64'
|
||||
if (-not $ClangCxx) { $ClangCxx = Join-Path $llvm 'bin\clang++.exe' }
|
||||
if (-not $ClangC) { $ClangC = Join-Path $llvm 'bin\clang.exe' }
|
||||
if (-not $Sysroot) { $Sysroot = Join-Path $llvm 'sysroot' }
|
||||
}
|
||||
foreach ($tool in $ClangCxx, $ClangC) { if (-not (Test-Path $tool)) { throw "Compiler not found: $tool" } }
|
||||
if (-not $Ninja) {
|
||||
$Ninja = Get-ChildItem -Path (Join-Path $sdkRoot 'cmake') -Filter ninja.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $Ninja) { $Ninja = (Get-Command ninja.exe -ErrorAction SilentlyContinue).Source }
|
||||
}
|
||||
if (-not $Ninja -or -not (Test-Path $Ninja)) { throw 'ninja.exe not found' }
|
||||
|
||||
# Translated shards need roughly 1.5 GB each at -O2; leave a margin for the rest of the machine.
|
||||
if ($TranslatedJobs -le 0) {
|
||||
$memoryGb = [math]::Floor((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)
|
||||
$TranslatedJobs = [math]::Max(1, [math]::Min([Environment]::ProcessorCount, [math]::Floor(($memoryGb - 4) / 1.5)))
|
||||
}
|
||||
|
||||
# The disc this translation was made from: recomp.yml's pins, which the translator enforced.
|
||||
$pins = [IO.File]::ReadAllText($Manifest)
|
||||
$gameId = [regex]::Match($pins, '\n\s*game_id:\s*(\w+)').Groups[1].Value
|
||||
$dolSha = [regex]::Match($pins, '(?s)\n\s*dol:.*?sha256:\s*([0-9a-fA-F]{64})').Groups[1].Value
|
||||
$relSha = [regex]::Match($pins, '(?s)\n\s*rel:.*?sha256:\s*([0-9a-fA-F]{64})').Groups[1].Value
|
||||
if (-not $gameId -or -not $dolSha -or -not $relSha) { throw "Cannot read the disc pins from $Manifest" }
|
||||
|
||||
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$library = Invoke-QuestGameBuild -KitDir $Kit -GeneratedDir $Generated -BuildDir $BuildDir `
|
||||
-ClangCxx $ClangCxx -ClangC $ClangC -Sysroot $Sysroot -Ninja $Ninja -TranslatedJobs $TranslatedJobs
|
||||
Write-Host ("Built {0} in {1:N1} min" -f $library, $stopwatch.Elapsed.TotalMinutes)
|
||||
|
||||
if (-not $Output) { $Output = Join-Path $BuildDir 'MarioKartWii.wcgame' }
|
||||
Write-Host 'MKWCBUILD:STEP:quest-package Packaging the game for Quest'
|
||||
$game = New-QuestGamePackage -Library $library -DataDir $Data -KitDir $Kit -GameId $gameId -DolSha256 $dolSha -RelSha256 $relSha `
|
||||
-BuiltBy $BuiltBy -OutputPath $Output
|
||||
Write-Host "Game package: $Output (kit $($game.kitFingerprint))"
|
||||
|
||||
if ($Install) {
|
||||
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
|
||||
$import = '/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Import'
|
||||
# The launcher creates Import itself and must own it to remove imported packages (see
|
||||
# Run-Quest.ps1 on adb-created directories), so only push into one that exists.
|
||||
$exists = ((& $adb shell "test -d '$import' && echo yes") | Out-String).Trim()
|
||||
if ($exists -ne 'yes') { throw 'The launcher has not created its Import folder yet; open the app once on the headset first.' }
|
||||
$remote = "$import/$([IO.Path]::GetFileName($Output))"
|
||||
& $adb push $Output $remote
|
||||
if ($LASTEXITCODE -ne 0) { throw "adb push failed ($LASTEXITCODE)" }
|
||||
# The folder belongs to the app; only the pushed file is the shell user's to open up.
|
||||
& $adb shell "chmod a+r '$remote'"
|
||||
Write-Host 'Pushed; the launcher imports it the next time it opens.'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Exports the Quest game kit (see QuestGameKit.psm1) from a configured and built Android CMake
|
||||
# tree. android/app/build.gradle.kts runs this after the native build and packages the result as
|
||||
# the APK's game_kit assets.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir <dir> -OutputDir <dir> -LlvmStrip <llvm-strip>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$CMakeBinaryDir,
|
||||
[Parameter(Mandatory)] [string]$OutputDir,
|
||||
[Parameter(Mandatory)] [string]$LlvmStrip
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force
|
||||
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$fingerprint = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip
|
||||
Write-Host "Quest game kit $fingerprint -> $OutputDir"
|
||||
@@ -0,0 +1,424 @@
|
||||
# The Quest game kit: everything libmain.so links except the translated game, plus the recipe
|
||||
# that compiles and links the player's own translation against it.
|
||||
#
|
||||
# The APK never contains translated Mario Kart code. Its assets carry this kit instead, and the
|
||||
# player's libmain.so is built from their own disc, either on the headset (the launcher's
|
||||
# on-device builder) or on a PC (Build-QuestGame.ps1, the PC launcher's "Build for Quest"), then
|
||||
# loaded from the app's private storage. Both builders replay one recipe, kit.json, which is
|
||||
# exported from CMake's own build graph so its flags and link order cannot drift from a normal
|
||||
# build:
|
||||
#
|
||||
# * the link line of mkw_quest_kit_probe (runtime/cmake/PublicProducts.cmake), which is
|
||||
# WiiCompiled without the game, becomes kit.json's link inputs, with {game:*} markers where
|
||||
# the game's objects sat in WiiCompiled's own link;
|
||||
# * the compile commands CMake recorded for one source of each generated kind (translated shard,
|
||||
# registration shard, disc-generated runtime source, blob assembly) become kit.json's
|
||||
# compile flags, with include paths reduced to the kit's copy of runtime/include.
|
||||
#
|
||||
# Placeholders in kit.json: {kit} the extracted kit, {sysroot} the NDK sysroot the compiler
|
||||
# uses, {workspace} the directory holding the translator's generated/ tree.
|
||||
#
|
||||
# Windows PowerShell 5.1 compatible.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$script:KitSchema = 1
|
||||
$script:GameSchema = 1
|
||||
|
||||
function ConvertTo-ForwardPath([string]$Path) {
|
||||
return [IO.Path]::GetFullPath($Path).Replace('\', '/').TrimEnd('/')
|
||||
}
|
||||
|
||||
function ConvertFrom-NinjaPath([string]$Text) {
|
||||
return $Text.Replace('$:', ':').Replace('$ ', ' ').Replace('$$', '$')
|
||||
}
|
||||
|
||||
# Splits a CMake compile command. CMake quotes only arguments that need it and escapes inner
|
||||
# quotes as \" (for example -DIMGUI_USER_CONFIG=\"aurora/imgui_config.h\").
|
||||
function Split-CommandLine([string]$Command) {
|
||||
$arguments = New-Object System.Collections.Generic.List[string]
|
||||
$current = New-Object System.Text.StringBuilder
|
||||
$inQuotes = $false
|
||||
$pending = $false
|
||||
for ($i = 0; $i -lt $Command.Length; $i++) {
|
||||
$c = $Command[$i]
|
||||
if ($c -eq '\' -and $i + 1 -lt $Command.Length -and $Command[$i + 1] -eq '"') {
|
||||
[void]$current.Append('"'); $i++; $pending = $true; continue
|
||||
}
|
||||
if ($c -eq '"') { $inQuotes = -not $inQuotes; $pending = $true; continue }
|
||||
if (-not $inQuotes -and [char]::IsWhiteSpace($c)) {
|
||||
if ($pending) { $arguments.Add($current.ToString()); [void]$current.Clear(); $pending = $false }
|
||||
continue
|
||||
}
|
||||
[void]$current.Append($c); $pending = $true
|
||||
}
|
||||
if ($pending) { $arguments.Add($current.ToString()) }
|
||||
return ,$arguments.ToArray()
|
||||
}
|
||||
|
||||
# One argument for a response file or a Windows command line (CommandLineToArgvW rules, which
|
||||
# clang's GNU response-file tokenizer reads the same way for these arguments).
|
||||
function ConvertTo-QuotedArgument([string]$Argument) {
|
||||
if ($Argument -notmatch '[\s"]') { return $Argument }
|
||||
return '"' + $Argument.Replace('"', '\"') + '"'
|
||||
}
|
||||
|
||||
function Get-Sha256Hex([string]$Path) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try { $bytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
|
||||
} finally { $sha.Dispose() }
|
||||
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
|
||||
}
|
||||
|
||||
function Get-StringSha256Hex([string]$Text) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text)) } finally { $sha.Dispose() }
|
||||
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
|
||||
}
|
||||
|
||||
# Identity of a runtime/include tree. Translated code compiles against these headers, so a game is
|
||||
# only built from a translation whose runtime headers are the kit's own.
|
||||
function Get-RuntimeIncludeFingerprint([string]$Directory) {
|
||||
$root = (Resolve-Path $Directory).Path.TrimEnd('\', '/')
|
||||
$lines = Get-ChildItem -Recurse -File $root | Sort-Object FullName | ForEach-Object {
|
||||
$_.FullName.Substring($root.Length + 1).Replace('\', '/') + ' ' + (Get-Sha256Hex $_.FullName)
|
||||
}
|
||||
return Get-StringSha256Hex ($lines -join "`n")
|
||||
}
|
||||
|
||||
# compile_commands.json as {file, command} objects. Windows PowerShell's ConvertFrom-Json refuses
|
||||
# documents over 2 MB, so 5.1 uses the serializer underneath it with the limit lifted.
|
||||
function Read-CompileCommands([string]$Path) {
|
||||
$text = [IO.File]::ReadAllText($Path)
|
||||
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||||
return @($text | ConvertFrom-Json | ForEach-Object { [pscustomobject]@{ file = $_.file; command = $_.command } })
|
||||
}
|
||||
Add-Type -AssemblyName System.Web.Extensions
|
||||
$serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer
|
||||
$serializer.MaxJsonLength = [int]::MaxValue
|
||||
return @($serializer.DeserializeObject($text) | ForEach-Object { [pscustomobject]@{ file = $_['file']; command = $_['command'] } })
|
||||
}
|
||||
|
||||
# Compile flags of one recorded command, reduced to what a generated source needs anywhere.
|
||||
function ConvertTo-KitCompileFlags {
|
||||
param([string[]]$Arguments, [string]$RuntimeInclude, [string]$Workspace)
|
||||
$flags = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 1; $i -lt $Arguments.Length; $i++) {
|
||||
$a = $Arguments[$i]
|
||||
switch -Regex -CaseSensitive ($a) {
|
||||
'^(-o|-c|-MT|-MF)$' { $i++; continue }
|
||||
'^(-MD|-MMD|-g|-Winvalid-pch)$' { continue }
|
||||
'^-isystem$' { $i++; continue }
|
||||
'^--sysroot=' { $flags.Add('--sysroot={sysroot}'); continue }
|
||||
'^-Xclang$' {
|
||||
if ($i + 3 -lt $Arguments.Length -and $Arguments[$i + 1] -eq '-include-pch') { $i += 3; continue }
|
||||
if ($i + 3 -lt $Arguments.Length -and $Arguments[$i + 1] -eq '-include') {
|
||||
# CMake's cmake_pch.hxx only includes the precompiled header; builders parse it
|
||||
# directly (a PCH is only valid for the compiler that wrote it).
|
||||
$i += 3; $flags.Add('-include'); $flags.Add('mkw_pch.h'); continue
|
||||
}
|
||||
$flags.Add($a); continue
|
||||
}
|
||||
'^-I' {
|
||||
$path = ConvertTo-ForwardPath $a.Substring(2)
|
||||
if ($path -eq $RuntimeInclude) { $flags.Add('-I{kit}/include') }
|
||||
elseif ($path -eq $Workspace) { $flags.Add('-I{workspace}') }
|
||||
continue
|
||||
}
|
||||
default { $flags.Add($a) }
|
||||
}
|
||||
}
|
||||
return ,$flags.ToArray()
|
||||
}
|
||||
|
||||
function Export-QuestGameKit {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$CMakeBinaryDir,
|
||||
[Parameter(Mandatory)] [string]$OutputDir,
|
||||
[Parameter(Mandatory)] [string]$RepoRoot,
|
||||
[Parameter(Mandatory)] [string]$LlvmStrip
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$binary = ConvertTo-ForwardPath $CMakeBinaryDir
|
||||
$runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include')
|
||||
|
||||
# The probe's link edge: explicit inputs are the objects, LINK_LIBRARIES the archives.
|
||||
$ninja = [IO.File]::ReadAllText("$binary/build.ninja")
|
||||
$edge = [regex]::Match($ninja, '(?m)^build (\S*libmkw_quest_kit_probe\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)')
|
||||
if (-not $edge.Success) { throw "No mkw_quest_kit_probe link edge in $binary/build.ninja" }
|
||||
$explicit = ($edge.Groups[3].Value.Replace('$ ', '<ninja-space>') -split ' \|')[0]
|
||||
$objects = @($explicit -split ' ' | Where-Object { $_ } | ForEach-Object { ConvertFrom-NinjaPath $_.Replace('<ninja-space>', '$ ') })
|
||||
$vars = @{}
|
||||
foreach ($line in ($edge.Groups[4].Value -split "\r?\n")) {
|
||||
if ($line -match '^ (\w+) = (.*)$') { $vars[$Matches[1]] = $Matches[2] }
|
||||
}
|
||||
$rule = [regex]::Match($ninja, "(?ms)^rule $([regex]::Escape($edge.Groups[2].Value))\r?\n.*?command = ([^\r\n]*)")
|
||||
if (-not $rule.Success) {
|
||||
$rules = [IO.File]::ReadAllText("$binary/CMakeFiles/rules.ninja")
|
||||
$rule = [regex]::Match($rules, "(?ms)^rule $([regex]::Escape($edge.Groups[2].Value))\r?\n.*?command = ([^\r\n]*)")
|
||||
}
|
||||
$target = [regex]::Match($rule.Groups[1].Value, '--target=(\S+)').Groups[1].Value
|
||||
if (-not $target) { throw "Cannot read the link target triple from rule $($edge.Groups[2].Value)" }
|
||||
|
||||
# Compile flags per generated kind, from CMake's own commands.
|
||||
$commands = Read-CompileCommands "$binary/compile_commands.json"
|
||||
function Find-Command([string]$Pattern) {
|
||||
$entry = @($commands | Where-Object { $_.file.Replace('\', '/') -match $Pattern })
|
||||
if ($entry.Count -eq 0) { throw "compile_commands.json has no source matching $Pattern" }
|
||||
return $entry[0]
|
||||
}
|
||||
$translatedEntry = Find-Command '/build_shards/base_common/[^/]+\.cpp$'
|
||||
$workspace = ConvertTo-ForwardPath ($translatedEntry.file.Replace('\', '/') -replace '/generated/build_shards/.*$', '')
|
||||
$flagsFor = {
|
||||
param($Entry)
|
||||
ConvertTo-KitCompileFlags -Arguments (Split-CommandLine $Entry.command) -RuntimeInclude $runtimeInclude -Workspace $workspace
|
||||
}
|
||||
$compile = [ordered]@{
|
||||
translated = & $flagsFor $translatedEntry
|
||||
product = & $flagsFor (Find-Command '/build_shards/base_registration/[^/]+\.cpp$')
|
||||
runtime = & $flagsFor (Find-Command '/generated/data_sections_init\.cpp$')
|
||||
asm = & $flagsFor (Find-Command 'data_sections_init_blobs[^/]*\.S$')
|
||||
}
|
||||
|
||||
if (Test-Path $OutputDir) { Remove-Item -Recurse -Force $OutputDir }
|
||||
$objectsDir = Join-Path $OutputDir 'objects'
|
||||
$libsDir = Join-Path $OutputDir 'libs'
|
||||
New-Item -ItemType Directory -Force $objectsDir, $libsDir | Out-Null
|
||||
Copy-Item -Recurse (Join-Path $RepoRoot 'runtime/include') (Join-Path $OutputDir 'include')
|
||||
|
||||
$copied = @{}
|
||||
$copy = {
|
||||
param([string]$Source, [string]$Directory)
|
||||
$full = ConvertTo-ForwardPath $(if ([IO.Path]::IsPathRooted($Source)) { $Source } else { "$binary/$Source" })
|
||||
if ($copied.ContainsKey($full)) { return $copied[$full] }
|
||||
$script:kitIndex++
|
||||
$name = '{0:D3}_{1}' -f $script:kitIndex, [IO.Path]::GetFileName($full)
|
||||
$destination = Join-Path (Join-Path $OutputDir $Directory) $name
|
||||
& $LlvmStrip --strip-debug -o $destination $full
|
||||
if ($LASTEXITCODE -ne 0) { throw "llvm-strip failed on $full" }
|
||||
$copied[$full] = "{kit}/$Directory/$name"
|
||||
return $copied[$full]
|
||||
}
|
||||
$script:kitIndex = 0
|
||||
|
||||
# Link inputs in the probe's order, with the game's slots marked where WiiCompiled has them.
|
||||
$inputs = New-Object System.Collections.Generic.List[string]
|
||||
$lastRuntime = -1
|
||||
for ($i = 0; $i -lt $objects.Count; $i++) {
|
||||
if ($objects[$i] -match '/mkw_runtime_common\.dir/') { $lastRuntime = $i }
|
||||
}
|
||||
for ($i = 0; $i -lt $objects.Count; $i++) {
|
||||
$inputs.Add((& $copy $objects[$i] 'objects'))
|
||||
if ($i -eq $lastRuntime) { $inputs.Add('{game:runtime}') }
|
||||
if ($objects[$i] -match '/base_product\.cpp\.o$') { $inputs.Add('{game:product}') }
|
||||
}
|
||||
if (-not $inputs.Contains('{game:runtime}') -or -not $inputs.Contains('{game:product}')) {
|
||||
throw 'The probe link line lacks the runtime or product objects the game slots follow'
|
||||
}
|
||||
foreach ($token in ((ConvertFrom-NinjaPath $vars['LINK_LIBRARIES']) -split ' ' | Where-Object { $_ })) {
|
||||
if ($token.StartsWith('-')) { $inputs.Add($token); continue }
|
||||
$path = $token.Replace('\', '/')
|
||||
if ($path -match '/sysroot/(.+)$') { $inputs.Add("{sysroot}/$($Matches[1])") }
|
||||
else { $inputs.Add((& $copy $token 'libs')) }
|
||||
if ($path -match '/libmkw_platform\.a$') { $inputs.Add('{game:translated}') }
|
||||
}
|
||||
if (-not $inputs.Contains('{game:translated}')) { throw 'The probe link line lacks libmkw_platform.a' }
|
||||
|
||||
$linkFlags = New-Object System.Collections.Generic.List[string]
|
||||
$linkFlags.Add("--target=$target"); $linkFlags.Add('--sysroot={sysroot}'); $linkFlags.Add('-fPIC')
|
||||
foreach ($name in 'LANGUAGE_COMPILE_FLAGS', 'ARCH_FLAGS', 'LINK_FLAGS') {
|
||||
if (-not $vars.ContainsKey($name)) { continue }
|
||||
$tokens = Split-CommandLine (ConvertFrom-NinjaPath $vars[$name])
|
||||
for ($i = 0; $i -lt $tokens.Length; $i++) {
|
||||
if ($tokens[$i] -eq '-g' -or $tokens[$i] -eq '-Wl,--unresolved-symbols=ignore-all') { continue }
|
||||
if ($tokens[$i] -eq '-Xlinker' -and $i + 1 -lt $tokens.Length -and $tokens[$i + 1].StartsWith('--dependency-file')) { $i++; continue }
|
||||
$linkFlags.Add($tokens[$i])
|
||||
}
|
||||
}
|
||||
$linkFlags.Add('-Wl,-soname,libmain.so')
|
||||
|
||||
$recipe = [ordered]@{
|
||||
schema = $script:KitSchema
|
||||
product = 'base'
|
||||
output = 'libmain.so'
|
||||
target = $target
|
||||
runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include')
|
||||
compile = $compile
|
||||
link = [ordered]@{ flags = $linkFlags.ToArray(); inputs = $inputs.ToArray() }
|
||||
}
|
||||
$recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress
|
||||
$files = Get-ChildItem -Recurse -File $OutputDir | Sort-Object FullName | ForEach-Object {
|
||||
$relative = $_.FullName.Substring((Resolve-Path $OutputDir).Path.Length).Replace('\', '/')
|
||||
"$relative $(Get-Sha256Hex $_.FullName)"
|
||||
}
|
||||
$recipe.fingerprint = Get-StringSha256Hex ($recipeJson + "`n" + ($files -join "`n"))
|
||||
[IO.File]::WriteAllText((Join-Path $OutputDir 'kit.json'), ($recipe | ConvertTo-Json -Depth 8), (New-Object Text.UTF8Encoding $false))
|
||||
return $recipe.fingerprint
|
||||
}
|
||||
|
||||
# The generated source lists emit-build-shards wrote into shards.cmake.
|
||||
function Read-ShardList {
|
||||
param([string]$ShardsCmake, [string]$Name)
|
||||
$text = [IO.File]::ReadAllText($ShardsCmake)
|
||||
$match = [regex]::Match($text, "(?s)set\($Name\s*(.*?)\)")
|
||||
if (-not $match.Success) { return @() }
|
||||
return @([regex]::Matches($match.Groups[1].Value, '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value.Replace('\', '/') })
|
||||
}
|
||||
|
||||
function Invoke-QuestGameBuild {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$KitDir,
|
||||
[Parameter(Mandatory)] [string]$GeneratedDir,
|
||||
[Parameter(Mandatory)] [string]$BuildDir,
|
||||
[Parameter(Mandatory)] [string]$ClangCxx,
|
||||
[Parameter(Mandatory)] [string]$ClangC,
|
||||
[Parameter(Mandatory)] [string]$Sysroot,
|
||||
[Parameter(Mandatory)] [string]$Ninja,
|
||||
[Parameter(Mandatory)] [int]$TranslatedJobs
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$kit = ConvertTo-ForwardPath $KitDir
|
||||
$generated = ConvertTo-ForwardPath $GeneratedDir
|
||||
$workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated)
|
||||
$recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json
|
||||
if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" }
|
||||
# The translation must come from the same release as the kit: its code is compiled against the
|
||||
# kit's runtime headers and linked with the kit's runtime objects.
|
||||
$workspaceInclude = Join-Path $workspace 'runtime/include'
|
||||
if (Test-Path $workspaceInclude) {
|
||||
if ((Get-RuntimeIncludeFingerprint $workspaceInclude) -ne $recipe.runtimeIncludeFingerprint) {
|
||||
throw ('The Quest app and the translation on this PC come from different WiiCompiled releases ' +
|
||||
'(their runtime headers differ). Update both to the same release, then build again.')
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force $BuildDir | Out-Null
|
||||
$build = ConvertTo-ForwardPath $BuildDir
|
||||
$expand = { param([string]$s) $s.Replace('{kit}', $kit).Replace('{sysroot}', (ConvertTo-ForwardPath $Sysroot)).Replace('{workspace}', $workspace) }
|
||||
|
||||
foreach ($kind in 'translated', 'product', 'runtime', 'asm') {
|
||||
$lines = @($recipe.compile.$kind | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) })
|
||||
[IO.File]::WriteAllText("$build/$kind.rsp", ($lines -join "`n"), (New-Object Text.UTF8Encoding $false))
|
||||
}
|
||||
$linkLines = @($recipe.link.flags | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) })
|
||||
[IO.File]::WriteAllText("$build/link.rsp", ($linkLines -join "`n"), (New-Object Text.UTF8Encoding $false))
|
||||
|
||||
# The Windows installer generates the blob assembly for PE/COFF; rewrite it for ELF exactly
|
||||
# as runtime/cmake/PublicProducts.cmake does.
|
||||
$blob = [IO.File]::ReadAllText("$generated/data_sections_init_blobs.S")
|
||||
$elf = $blob.Replace('.section .rdata,"dr"', '.section .rodata,"a",@progbits')
|
||||
if ($elf -ne $blob) { $elf += "`n.section .note.GNU-stack,`"`",@progbits`n" }
|
||||
[IO.File]::WriteAllText("$build/data_sections_init_blobs_android.S", $elf, (New-Object Text.UTF8Encoding $false))
|
||||
|
||||
$shards = "$generated/build_shards/shards.cmake"
|
||||
$sources = [ordered]@{
|
||||
runtime = @("$generated/data_sections_init.cpp", "$generated/guest_symbol_table.cpp")
|
||||
runtimeAsm = @("$build/data_sections_init_blobs_android.S")
|
||||
product = @(Read-ShardList $shards 'MKW_BASE_REGISTRATION_SOURCES')
|
||||
translated = @((Read-ShardList $shards 'MKW_BASE_COMMON_SHARDS') + (Read-ShardList $shards 'MKW_BASE_PORTABLE_SENSITIVE_SHARDS'))
|
||||
}
|
||||
if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" }
|
||||
|
||||
$ninjaText = New-Object System.Text.StringBuilder
|
||||
$escape = { param([string]$p) $p.Replace('$', '$$').Replace(':', '$:').Replace(' ', '$ ') }
|
||||
[void]$ninjaText.AppendLine('ninja_required_version = 1.10')
|
||||
[void]$ninjaText.AppendLine("pool translated`n depth = $TranslatedJobs")
|
||||
[void]$ninjaText.AppendLine("rule cxx`n command = $(ConvertTo-QuotedArgument $ClangCxx) @`$flags -c `$in -o `$out`n description = Compiling `$in")
|
||||
[void]$ninjaText.AppendLine("rule asm`n command = $(ConvertTo-QuotedArgument $ClangC) @`$flags -c `$in -o `$out`n description = Assembling `$in")
|
||||
[void]$ninjaText.AppendLine("rule link`n command = $(ConvertTo-QuotedArgument $ClangCxx) @`$flags -o `$out @`$out.rsp`n rspfile = `$out.rsp`n rspfile_content = `$inputs`n description = Linking `$out")
|
||||
$objectsOf = @{ runtime = @(); product = @(); translated = @() }
|
||||
$seen = @{}
|
||||
foreach ($kind in 'runtime', 'runtimeAsm', 'product', 'translated') {
|
||||
foreach ($source in $sources[$kind]) {
|
||||
$stem = [IO.Path]::GetFileNameWithoutExtension($source)
|
||||
$object = "obj/$stem.o"
|
||||
if ($seen.ContainsKey($object)) { throw "Two generated sources share the object name $object" }
|
||||
$seen[$object] = $true
|
||||
$rule = if ($kind -eq 'runtimeAsm') { 'asm' } else { 'cxx' }
|
||||
$flags = switch ($kind) { 'runtimeAsm' { 'asm.rsp' } default { "$kind.rsp" } }
|
||||
[void]$ninjaText.AppendLine("build $(& $escape $object): $rule $(& $escape $source)`n flags = $(& $escape "$build/$flags")")
|
||||
if ($kind -eq 'translated') { [void]$ninjaText.AppendLine(' pool = translated') }
|
||||
$slot = if ($kind -eq 'runtimeAsm') { 'runtime' } else { $kind }
|
||||
$objectsOf[$slot] += "$build/$object"
|
||||
}
|
||||
}
|
||||
$linkInputs = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($linkInput in $recipe.link.inputs) {
|
||||
switch ($linkInput) {
|
||||
'{game:runtime}' { $objectsOf.runtime | ForEach-Object { $linkInputs.Add($_) } }
|
||||
'{game:product}' { $objectsOf.product | ForEach-Object { $linkInputs.Add($_) } }
|
||||
'{game:translated}' {
|
||||
# Archive semantics, as libmkw_base_shared.a has in a CMake build.
|
||||
$linkInputs.Add('-Wl,--start-lib')
|
||||
$objectsOf.translated | ForEach-Object { $linkInputs.Add($_) }
|
||||
$linkInputs.Add('-Wl,--end-lib')
|
||||
}
|
||||
default { $linkInputs.Add((& $expand $linkInput)) }
|
||||
}
|
||||
}
|
||||
$allObjects = @($objectsOf.runtime + $objectsOf.product + $objectsOf.translated | ForEach-Object { & $escape $_.Substring($build.Length + 1) })
|
||||
$rspInputs = ($linkInputs | ForEach-Object { ConvertTo-QuotedArgument $_ }) -join ' '
|
||||
[void]$ninjaText.AppendLine("build libmain.so: link $($allObjects -join ' ')`n flags = $(& $escape "$build/link.rsp")`n inputs = $($rspInputs.Replace('$', '$$'))")
|
||||
[IO.File]::WriteAllText("$build/build.ninja", $ninjaText.ToString(), (New-Object Text.UTF8Encoding $false))
|
||||
|
||||
# Ninja's progress goes to the console, not into this function's return value; its [n/N]
|
||||
# lines are what WiiCompiled Setup turns into build progress.
|
||||
Write-Host 'MKWCBUILD:STEP:quest-compile Compiling the game for Quest'
|
||||
& $Ninja -C $build | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "Game build failed (ninja exit $LASTEXITCODE)" }
|
||||
return "$build/libmain.so"
|
||||
}
|
||||
|
||||
# A .wcgame: the built library and what it was built from, for the headset to import. With
|
||||
# -DataDir it also carries the extracted disc (DATA/...), written without compression, so a player who
|
||||
# builds on a PC copies one file to the headset and never needs the disc image there.
|
||||
function New-QuestGamePackage {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Library,
|
||||
[string]$DataDir = '',
|
||||
[Parameter(Mandatory)] [string]$KitDir,
|
||||
[Parameter(Mandatory)] [string]$GameId,
|
||||
[Parameter(Mandatory)] [string]$DolSha256,
|
||||
[Parameter(Mandatory)] [string]$RelSha256,
|
||||
[Parameter(Mandatory)] [string]$BuiltBy,
|
||||
[Parameter(Mandatory)] [string]$OutputPath
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$recipe = [IO.File]::ReadAllText((Join-Path $KitDir 'kit.json')) | ConvertFrom-Json
|
||||
$game = [ordered]@{
|
||||
schema = $script:GameSchema
|
||||
profile = $recipe.product
|
||||
gameId = $GameId
|
||||
dolSha256 = $DolSha256
|
||||
relSha256 = $RelSha256
|
||||
kitFingerprint = $recipe.fingerprint
|
||||
library = $recipe.output
|
||||
librarySha256 = Get-Sha256Hex $Library
|
||||
includesData = [bool]$DataDir
|
||||
builtBy = $BuiltBy
|
||||
builtAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
}
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
if (Test-Path $OutputPath) { Remove-Item -Force $OutputPath }
|
||||
$zip = [IO.Compression.ZipFile]::Open($OutputPath, 'Create')
|
||||
try {
|
||||
$entry = $zip.CreateEntry('game.json')
|
||||
$writer = New-Object IO.StreamWriter($entry.Open(), (New-Object Text.UTF8Encoding $false))
|
||||
try { $writer.Write(($game | ConvertTo-Json)) } finally { $writer.Dispose() }
|
||||
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $recipe.output, 'Optimal')
|
||||
if ($DataDir) {
|
||||
$dataRoot = (Resolve-Path $DataDir).Path.TrimEnd('\', '/')
|
||||
foreach ($file in Get-ChildItem -Recurse -File $dataRoot) {
|
||||
$name = 'DATA/' + $file.FullName.Substring($dataRoot.Length + 1).Replace('\', '/')
|
||||
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file.FullName, $name, 'NoCompression')
|
||||
}
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
return $game
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Export-QuestGameKit, Invoke-QuestGameBuild, New-QuestGamePackage
|
||||
+16
-2
@@ -3,7 +3,8 @@
|
||||
Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 2,
|
||||
Quest 3, Quest 3S and Quest Pro. The full design, build walkthrough and current
|
||||
status live in [docs/quest-port.md](../docs/quest-port.md); this directory only
|
||||
holds the Gradle project and its two helper scripts.
|
||||
holds the Gradle project, its helper scripts, the game kit tooling
|
||||
(`QuestGameKit.psm1`, `Build-QuestGame.ps1`) and `nod-jni`.
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once
|
||||
@@ -14,10 +15,23 @@ powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRe
|
||||
The translated game (the translator's `generated/` tree for **your own**
|
||||
PAL `RMCP01` disc) is passed with `-Generated <dir>`; it defaults to the
|
||||
installer workspace next to this checkout. No game data is ever part of the
|
||||
APK: push the extracted disc afterwards to
|
||||
APK, and neither is any translated game code: the base APK carries a game kit,
|
||||
and your `libmain.so` is built from your own disc and imported
|
||||
(`Build-QuestGame.ps1 -Install`, or **Import from computer** with its `.wcgame`).
|
||||
Copy your disc image to the headset (for example `adb push MarioKart.iso
|
||||
/sdcard/Download/`) and press **Select disc image** in the launcher, which checks
|
||||
and extracts it with nod, as the PC installer does. Or push an already extracted
|
||||
disc to
|
||||
|
||||
```
|
||||
/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA
|
||||
```
|
||||
|
||||
`Config.toml`, saves and logs live in the same `WiiCompiledOpenXRVR` directory.
|
||||
|
||||
The app opens on a 2D launcher panel modelled on the PC launcher (WheelWizard VR):
|
||||
**Home** starts the game in the headset and says when DATA is missing, and
|
||||
**Settings** edits `Config.toml` (VR camera, render scale, virtual screen,
|
||||
resolution, controllers, audio). The game itself is `QuestActivity`, in its own
|
||||
`:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity`
|
||||
still starts it directly.
|
||||
@@ -1,5 +1,6 @@
|
||||
# Installs the built APK on the connected Quest, places the game data, launches
|
||||
# the activity and collects diagnostics for one session.
|
||||
# the game activity directly (skipping the launcher panel) and collects
|
||||
# diagnostics for one session.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk <path>] [-Data <extracted disc dir>]
|
||||
# [-Seconds 60] [-NoLaunch] [-SkipInstall]
|
||||
@@ -96,8 +97,9 @@ $logcat = Join-Path $runDir 'logcat.txt'
|
||||
& $adb logcat -d -v time | Out-File -FilePath $logcat -Encoding utf8
|
||||
Write-Host "logcat: $logcat"
|
||||
|
||||
$running = ((& $adb shell pidof $package) | Out-String).Trim()
|
||||
if ($running) { Write-Host "Process still running (pid $running)" } else { Write-Warning 'Process is not running' }
|
||||
# The game runs in its own process; the package's main process is the launcher panel.
|
||||
$running = ((& $adb shell pidof "${package}:game") | Out-String).Trim()
|
||||
if ($running) { Write-Host "Game process still running (pid $running)" } else { Write-Warning 'Game process is not running' }
|
||||
|
||||
$latest = ((& $adb shell "ls -t '$appDir/Logs' 2>/dev/null | head -1") | Out-String).Trim()
|
||||
if ($latest) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import javax.inject.Inject
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
@@ -26,6 +27,62 @@ val prepareRuntimeResources by tasks.registering(Copy::class) {
|
||||
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})"""),
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -38,6 +95,11 @@ android {
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-quest"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
for ((name, value) in discPins) {
|
||||
buildConfigField("String", name, "\"$value\"")
|
||||
}
|
||||
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
@@ -65,7 +127,10 @@ android {
|
||||
dimension = "profile"
|
||||
buildConfigField("String", "MAIN_LIBRARY", "\"main\"")
|
||||
buildConfigField("String", "PROFILE", "\"base\"")
|
||||
externalNativeBuild { cmake { targets += "WiiCompiled" } }
|
||||
// The APK carries the game kit, not the game: the player's libmain.so is built from
|
||||
// their own disc and loaded from private storage (see android/QuestGameKit.psm1).
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe" } }
|
||||
}
|
||||
create("retroRewind") {
|
||||
dimension = "profile"
|
||||
@@ -73,6 +138,9 @@ android {
|
||||
versionNameSuffix = "-retro-rewind"
|
||||
buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"")
|
||||
buildConfigField("String", "PROFILE", "\"retro_rewind\"")
|
||||
// Not moved to the game kit yet: this flavour still bundles its translated library,
|
||||
// so a Retro Rewind APK is for local use only.
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "false")
|
||||
externalNativeBuild { cmake { targets += "RetroRewind" } }
|
||||
}
|
||||
}
|
||||
@@ -92,6 +160,7 @@ android {
|
||||
|
||||
sourceSets.named("main") {
|
||||
assets.srcDir(runtimeResources)
|
||||
jniLibs.srcDir(nodJniLibs)
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -101,6 +170,8 @@ android {
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = false
|
||||
// Built only to produce the game kit; its objects travel as assets/game_kit.
|
||||
excludes += "**/libmkw_quest_kit_probe.so"
|
||||
}
|
||||
}
|
||||
lint {
|
||||
@@ -112,6 +183,71 @@ 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: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
|
||||
val probe = File(app, "build/intermediates/cxx").walkTopDown()
|
||||
.filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
|
||||
.maxByOrNull { it.lastModified() }
|
||||
?: throw GradleException("No libmkw_quest_kit_probe.so; the native build did not produce the game kit probe")
|
||||
val configuration = probe.parentFile.parentFile.parentFile // <BuildType>/<hash>
|
||||
val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a")
|
||||
if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir")
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withFlavor("profile" to "base")) { variant ->
|
||||
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
||||
// AGP packages every library in the CMake output directory, so a game library left over
|
||||
// from a build that still linked the game would otherwise ship in the APK.
|
||||
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"))
|
||||
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
@@ -121,4 +257,7 @@ kotlin {
|
||||
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")
|
||||
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.wiicompiled.quest
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipFile
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* What an app targeting API 29+ may run from its own private storage, which is
|
||||
* where an on-device build puts the compiler and the compiled game.
|
||||
*
|
||||
* Run with `adb shell am instrument`, never `gradlew connectedAndroidTest`: that
|
||||
* task uninstalls the app afterwards, and with it the player's extracted DATA.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivateCodeExecutionTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val probe = File(context.filesDir, "probe").apply { mkdirs() }
|
||||
|
||||
@Test
|
||||
fun loadsALibraryFromPrivateStorage() {
|
||||
val library = File(probe, "libnod_jni_copy.so")
|
||||
ZipFile(context.applicationInfo.sourceDir).use { apk ->
|
||||
apk.getInputStream(apk.getEntry("lib/arm64-v8a/libnod_jni.so")).use { input ->
|
||||
library.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
}
|
||||
report("System.load from filesDir") { System.load(library.absolutePath); "loaded" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runsABinaryFromPrivateStorage() {
|
||||
val binary = File(probe, "toybox")
|
||||
File("/system/bin/toybox").inputStream().use { input -> binary.outputStream().use { input.copyTo(it) } }
|
||||
binary.setExecutable(true)
|
||||
report("exec from filesDir") { run(binary.absolutePath, "echo", "direct") }
|
||||
report("exec through /system/bin/linker64") { run("/system/bin/linker64", binary.absolutePath, "echo", "linker") }
|
||||
}
|
||||
|
||||
private fun run(vararg command: String): String {
|
||||
val process = ProcessBuilder(*command).redirectErrorStream(true).start()
|
||||
val output = process.inputStream.bufferedReader().readText().trim()
|
||||
process.waitFor(30, TimeUnit.SECONDS)
|
||||
return "exit ${process.exitValue()}: $output"
|
||||
}
|
||||
|
||||
private fun report(what: String, probe: () -> String) {
|
||||
val outcome = try {
|
||||
probe()
|
||||
} catch (t: Throwable) {
|
||||
"FAILED ${t.javaClass.simpleName}: ${t.message}"
|
||||
}
|
||||
Log.i(TAG, "$what -> $outcome")
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(0, android.os.Bundle().apply { putString("stream", "$what -> $outcome\n") })
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "WiiCompiledProbe"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- The launcher's disc image extraction runs as a foreground service that keeps the CPU awake. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- The Khronos loader brokers the system OpenXR runtime through this permission. -->
|
||||
<uses-permission android:name="org.khronos.openxr.permission.OPENXR_SYSTEM" />
|
||||
|
||||
@@ -38,6 +42,35 @@
|
||||
<!-- Let the runtime move CPU budget to the GPU; Aurora's replay is GPU-bound. -->
|
||||
<meta-data android:name="com.oculus.trade_cpu_for_gpu_amount" android:value="-1" />
|
||||
|
||||
<!-- The app opens on this 2D panel: Play starts QuestActivity, Settings edits Config.toml. -->
|
||||
<activity
|
||||
android:name=".launcher.LauncherActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|density"
|
||||
android:exported="true"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.WiiCompiled.Launcher">
|
||||
<!-- Horizon OS panel size. -->
|
||||
<layout
|
||||
android:defaultWidth="1024dp"
|
||||
android:defaultHeight="640dp"
|
||||
android:minWidth="760dp"
|
||||
android:minHeight="520dp" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Extracts the player's disc image and imports games built on a PC (launcher process). -->
|
||||
<service
|
||||
android:name=".launcher.GameSetupService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- The immersive game, as DolphinXR's EmulationActivity: VR category, no
|
||||
launcher entry, still exported so adb can start it directly. Its own
|
||||
process, which QuestActivity ends when the game closes: SDL and the
|
||||
runtime cannot start twice in one process. -->
|
||||
<activity
|
||||
android:name=".QuestActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode|density"
|
||||
@@ -45,6 +78,7 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:process=":game"
|
||||
android:resizeableActivity="false"
|
||||
android:screenOrientation="landscape">
|
||||
<property
|
||||
@@ -53,7 +87,7 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="com.oculus.intent.category.VR" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
@@ -49,8 +49,9 @@ endif()
|
||||
|
||||
add_subdirectory("${MKW_REPO_ROOT}/runtime" wiicompiled-runtime)
|
||||
|
||||
# App-owned JNI glue lives in each product library SDLActivity loads.
|
||||
foreach(product IN ITEMS WiiCompiled RetroRewind)
|
||||
# App-owned JNI glue lives in each product library SDLActivity loads, and in the game kit
|
||||
# probe (runtime/cmake/PublicProducts.cmake) whose objects the player's game is linked with.
|
||||
foreach(product IN ITEMS WiiCompiled RetroRewind mkw_quest_kit_probe)
|
||||
if(TARGET ${product})
|
||||
target_sources(${product} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/quest_surface_jni.cpp")
|
||||
target_include_directories(${product} PRIVATE "${MKW_REPO_ROOT}/aurora-main/include")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.wiicompiled.quest
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The player's compiled game: libmain.so built from their own disc against the game kit this
|
||||
* APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with.
|
||||
*
|
||||
* It lives in internal private storage, the one place Android lets an app load native code it
|
||||
* did not install (execution from shared or external storage is blocked). A library built for
|
||||
* another kit, meaning another app version's runtime, is Stale and must be rebuilt: it would link
|
||||
* against runtime objects this APK no longer has.
|
||||
*/
|
||||
object GameLibrary {
|
||||
|
||||
enum class Status { Missing, Stale, Ready }
|
||||
|
||||
const val LIBRARY_NAME = "libmain.so"
|
||||
const val MANIFEST_NAME = "game.json"
|
||||
|
||||
@Volatile
|
||||
private var cachedKitFingerprint: String? = null
|
||||
|
||||
fun directory(context: Context): File = File(context.filesDir, "game/${BuildConfig.PROFILE}")
|
||||
|
||||
fun library(context: Context): File = File(directory(context), LIBRARY_NAME)
|
||||
|
||||
fun manifest(context: Context): JSONObject? = File(directory(context), MANIFEST_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
|
||||
/** The fingerprint of the kit in this APK's assets, which every game must be built against. */
|
||||
fun kitFingerprint(context: Context): String? {
|
||||
cachedKitFingerprint?.let { return it }
|
||||
val fingerprint = runCatching {
|
||||
context.assets.open("game_kit/kit.json").use { JSONObject(it.reader().readText()).getString("fingerprint") }
|
||||
}.getOrNull()
|
||||
cachedKitFingerprint = fingerprint
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
fun status(context: Context): Status {
|
||||
if (!BuildConfig.EXTERNAL_GAME) {
|
||||
return Status.Ready
|
||||
}
|
||||
val manifest = manifest(context)
|
||||
if (manifest == null || !library(context).isFile) {
|
||||
return Status.Missing
|
||||
}
|
||||
return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.wiicompiled.quest
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Where the game's user files live on the headset. The launcher and the game
|
||||
* activity run in different processes and both resolve every path here, so
|
||||
* they cannot disagree about which Config.toml or DATA directory is meant.
|
||||
*
|
||||
* Everything sits under Android/data/<package>/files/WiiCompiledOpenXRVR, the
|
||||
* one directory a player can reach over adb: the extracted disc (DATA),
|
||||
* Config.toml, NAND saves and per-run logs.
|
||||
*/
|
||||
object GameStorage {
|
||||
|
||||
// Must match kApplicationDirectoryName in runtime/include/runtime_config.h.
|
||||
const val APP_DIRECTORY = "WiiCompiledOpenXRVR"
|
||||
const val DISC_DIRECTORY = "DATA"
|
||||
|
||||
enum class DiscStatus { Missing, Incomplete, Ready }
|
||||
|
||||
fun dataRoot(context: Context): File = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
|
||||
fun gameRoot(context: Context): File = File(dataRoot(context), APP_DIRECTORY)
|
||||
|
||||
fun discDirectory(context: Context): File = File(gameRoot(context), DISC_DIRECTORY)
|
||||
|
||||
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
|
||||
|
||||
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
|
||||
|
||||
/** Game packages dropped here (adb push, Build-QuestGame.ps1 -Install) are imported when the launcher opens. */
|
||||
fun importDirectory(context: Context): File = File(gameRoot(context), "Import")
|
||||
|
||||
/**
|
||||
* Whether DATA holds what the runtime's DVD layer accepts (IsDvdDataRoot in
|
||||
* runtime/src/hle/storage/dvd.cpp): the whole extracted partition, not just
|
||||
* its files/ folder. Incomplete also covers a tree adb left unreadable to
|
||||
* the app, which looks the same from here.
|
||||
*/
|
||||
fun discStatus(context: Context): DiscStatus {
|
||||
val disc = discDirectory(context)
|
||||
if (!disc.isDirectory) {
|
||||
return DiscStatus.Missing
|
||||
}
|
||||
val usable = File(disc, "files").isDirectory && File(disc, "sys/fst.bin").isFile
|
||||
return if (usable) DiscStatus.Ready else DiscStatus.Incomplete
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the game directory and a first Config.toml with VR on and the
|
||||
* disc path filled in. An existing file is left alone: the launcher, the
|
||||
* in-headset settings panel and the player all edit it in place.
|
||||
*/
|
||||
fun prepare(context: Context): File {
|
||||
val gameRoot = gameRoot(context)
|
||||
gameRoot.mkdirs()
|
||||
// Created by the app so it owns it and can remove imported packages; adb only adds files.
|
||||
importDirectory(context).mkdirs()
|
||||
val config = configFile(context)
|
||||
if (!config.isFile) {
|
||||
val disc = discDirectory(context).absolutePath
|
||||
config.writeText(
|
||||
"""
|
||||
# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.
|
||||
[paths]
|
||||
dvd_root = "$disc"
|
||||
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
|
||||
[vr]
|
||||
enabled = true
|
||||
render_scale = 1.0
|
||||
""".trimIndent() + "\n",
|
||||
)
|
||||
}
|
||||
return gameRoot
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package org.wiicompiled.quest
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
@@ -11,7 +14,9 @@ import org.libsdl.app.SDLActivity
|
||||
import org.libsdl.app.SDLSurface
|
||||
|
||||
/**
|
||||
* The one activity of the standalone Quest build.
|
||||
* The game: the immersive activity that runs the native runtime and the player's
|
||||
* own compiled game. The app opens on [org.wiicompiled.quest.launcher.LauncherActivity],
|
||||
* whose Play button starts this one.
|
||||
*
|
||||
* SDLActivity owns the surface, the Java-side event pump and the JNI plumbing
|
||||
* the OpenXR loader needs (the runtime fetches the JavaVM and this activity
|
||||
@@ -19,44 +24,78 @@ import org.libsdl.app.SDLSurface
|
||||
* adds is everything the native runtime cannot discover on its own:
|
||||
*
|
||||
* - the data directory the player fills with the extracted disc
|
||||
* (Android/data/<package>/files/WiiCompiled/DATA) and where Config.toml,
|
||||
* saves and logs live;
|
||||
* - the bundled read-only runtime resources, unpacked from the APK once;
|
||||
* - a Config.toml with VR enabled and the disc path filled in.
|
||||
* (Android/data/<package>/files/WiiCompiledOpenXRVR/DATA) and where
|
||||
* Config.toml, saves and logs live ([GameStorage]);
|
||||
* - the bundled read-only runtime resources, unpacked from the APK once.
|
||||
*
|
||||
* The paths are handed over through the environment before SDLActivity's own
|
||||
* onCreate loads the native libraries, because the runtime resolves them from
|
||||
* static initialisers where SDL's JNI helpers are not yet usable.
|
||||
*
|
||||
* This activity lives in its own `:game` process, and that process ends with
|
||||
* it. Neither SDL nor the runtime can start a second time in one process:
|
||||
* SDLActivity calls System.exit on a re-created activity once SDL_main has run,
|
||||
* and guest memory, fibers and the OpenXR device are process-wide. A fresh
|
||||
* process per session also keeps the launcher alive when the game exits.
|
||||
*/
|
||||
class QuestActivity : SDLActivity() {
|
||||
|
||||
override fun getLibraries(): Array<String> = arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
|
||||
override fun getLibraries(): Array<String> =
|
||||
if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
|
||||
|
||||
/**
|
||||
* The game is not part of the APK: it is the libmain.so the player built from their own disc
|
||||
* ([GameLibrary]), loaded from private storage. Its libSDL3.so, libpng16.so and
|
||||
* libc++_shared.so dependencies resolve against the ones this APK installed.
|
||||
*/
|
||||
override fun loadLibraries() {
|
||||
super.loadLibraries()
|
||||
if (BuildConfig.EXTERNAL_GAME) {
|
||||
// SDLActivity reports a failed load in its own error dialog and never starts the game.
|
||||
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
|
||||
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
|
||||
}
|
||||
System.load(GameLibrary.library(this).absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMainSharedObject(): String =
|
||||
if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject()
|
||||
|
||||
override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val dataRoot = (getExternalFilesDir(null) ?: filesDir)
|
||||
val gameRoot = File(dataRoot, APP_DIRECTORY)
|
||||
gameRoot.mkdirs()
|
||||
GameStorage.prepare(this)
|
||||
val resources = unpackRuntimeResources()
|
||||
|
||||
Os.setenv("MKW_ANDROID_DATA_DIR", dataRoot.absolutePath, true)
|
||||
Os.setenv("MKW_ANDROID_DATA_DIR", GameStorage.dataRoot(this).absolutePath, true)
|
||||
Os.setenv("MKW_ANDROID_RESOURCES_DIR", resources.absolutePath, true)
|
||||
ensureConfig(gameRoot)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!File(gameRoot, DISC_DIRECTORY).isDirectory && !mBrokenLibraries) {
|
||||
Log.w(TAG, "No extracted disc found under ${gameRoot.absolutePath}")
|
||||
// The launcher does not offer Play without DATA; this covers a direct
|
||||
// start, such as adb am start.
|
||||
if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing && !mBrokenLibraries) {
|
||||
val disc = GameStorage.discDirectory(this)
|
||||
Log.w(TAG, "No extracted disc found under ${disc.parentFile?.absolutePath}")
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.missing_game_data_title))
|
||||
.setMessage(getString(R.string.missing_game_data_message, File(gameRoot, DISC_DIRECTORY).absolutePath))
|
||||
.setMessage(getString(R.string.missing_game_data_message, disc.absolutePath))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// SDLActivity asks SDL_main to quit and waits up to a second for it.
|
||||
super.onDestroy()
|
||||
// Ended from the main looper so the activity manager has already
|
||||
// recorded this destruction and returns to the launcher.
|
||||
Log.i(TAG, "Game activity destroyed; ending the game process")
|
||||
Handler(Looper.getMainLooper()).post { Process.killProcess(Process.myPid()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies runtime_resources/ from the APK assets into private storage the
|
||||
* first time this build runs. A stamp file keyed on the version code keeps
|
||||
@@ -92,37 +131,7 @@ class QuestActivity : SDLActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a first Config.toml pointing at the disc directory with VR on.
|
||||
* An existing file is left alone: the settings overlay edits it in place.
|
||||
*/
|
||||
private fun ensureConfig(gameRoot: File) {
|
||||
val config = File(gameRoot, "Config.toml")
|
||||
if (config.isFile) {
|
||||
return
|
||||
}
|
||||
val disc = File(gameRoot, DISC_DIRECTORY).absolutePath
|
||||
config.writeText(
|
||||
"""
|
||||
# WiiCompiled Quest configuration. Edit with adb pull/push or the in-game overlay.
|
||||
[paths]
|
||||
dvd_root = "$disc"
|
||||
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
|
||||
[vr]
|
||||
enabled = true
|
||||
render_scale = 1.0
|
||||
""".trimIndent() + "\n",
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WiiCompiledQuest"
|
||||
// Must match kApplicationDirectoryName in runtime/include/runtime_config.h.
|
||||
private const val APP_DIRECTORY = "WiiCompiledOpenXRVR"
|
||||
private const val DISC_DIRECTORY = "DATA"
|
||||
private companion object {
|
||||
const val TAG = "WiiCompiledQuest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Config.toml on disk. Every edit re-reads the file first, so a value the
|
||||
* in-headset panel wrote meanwhile is never overwritten with a stale copy.
|
||||
*/
|
||||
class ConfigStore(val file: File) {
|
||||
|
||||
/** The current file, empty when it does not exist yet, or null when it cannot be read. */
|
||||
fun load(): TomlConfig? {
|
||||
if (!file.isFile) {
|
||||
return TomlConfig.parse("")
|
||||
}
|
||||
return try {
|
||||
TomlConfig.parse(file.readText())
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Cannot read ${file.absolutePath}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies [edit] to a fresh read and writes the result back. */
|
||||
fun update(edit: (TomlConfig) -> Unit): Boolean {
|
||||
// A file that exists but cannot be read must not be replaced by one
|
||||
// holding only this edit.
|
||||
val config = load() ?: return false
|
||||
edit(config)
|
||||
val text = config.text()
|
||||
return try {
|
||||
file.parentFile?.mkdirs()
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
temporary.writeText(text)
|
||||
if (!temporary.renameTo(file)) {
|
||||
temporary.delete()
|
||||
file.writeText(text)
|
||||
}
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Cannot write ${file.absolutePath}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "WiiCompiledLauncher"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The PC installer's acceptance rules for a player's disc image, in one place:
|
||||
* Launcher/WiiCompiled.Setup.Windows/InputValidation.cs (formats, game ID) and
|
||||
* InstallerEngine.ValidateExtractedGame (the clean PAL main.dol and StaticR.rel).
|
||||
* The pins come from projects/mkwii/recomp.yml through BuildConfig.
|
||||
*/
|
||||
class DiscChecks(
|
||||
private val gameId: String,
|
||||
private val dolSha256: String,
|
||||
private val relSha256: String,
|
||||
) {
|
||||
|
||||
data class Header(val gameId: String, val title: String, val extractedBytes: Long)
|
||||
|
||||
/** Null when [displayName] is a format nod reads, otherwise the message to show. */
|
||||
fun extensionError(displayName: String?): String? {
|
||||
val extension = displayName?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty()
|
||||
return if (extension in SUPPORTED_EXTENSIONS) null else FORMAT_MESSAGE
|
||||
}
|
||||
|
||||
fun compatibilityError(header: Header): String? {
|
||||
if (header.gameId.equals(gameId, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
return "This build supports Mario Kart Wii PAL ($gameId). The selected image is " +
|
||||
"${header.gameId} (${header.title}, ${region(header.gameId)})."
|
||||
}
|
||||
|
||||
fun revisionError(dol: ByteArray, rel: ByteArray): String? {
|
||||
if (sha256(dol).equals(dolSha256, ignoreCase = true) && sha256(rel).equals(relSha256, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
return "The disc is $gameId but does not match the supported clean PAL revision. " +
|
||||
"Patched or otherwise modified game code cannot be installed safely."
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DOL_PATH = "sys/main.dol"
|
||||
const val REL_PATH = "files/rel/StaticR.rel"
|
||||
const val FST_PATH = "sys/fst.bin"
|
||||
|
||||
private val SUPPORTED_EXTENSIONS = setOf("iso", "gcm", "gcz", "ciso", "wbfs", "wia", "rvz")
|
||||
private const val FORMAT_MESSAGE =
|
||||
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format."
|
||||
|
||||
/** NodDisc.header's "<game id>\n<title>\n<bytes>". */
|
||||
fun parseHeader(text: String): Header {
|
||||
val lines = text.split('\n')
|
||||
return Header(
|
||||
gameId = lines.getOrElse(0) { "" },
|
||||
title = lines.getOrElse(1) { "" },
|
||||
extractedBytes = lines.getOrNull(2)?.toLongOrNull() ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun region(gameId: String): String = when (gameId.getOrNull(3)) {
|
||||
'P' -> "PAL"
|
||||
'E' -> "NTSC-U"
|
||||
'J' -> "NTSC-J"
|
||||
'K' -> "Korea"
|
||||
'W' -> "Taiwan"
|
||||
null -> "Unknown"
|
||||
else -> gameId[3].toString()
|
||||
}
|
||||
|
||||
fun sha256(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
* The player's disc image becomes DATA, the way the PC installer does it
|
||||
* (Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs): read the header and require RMCP01,
|
||||
* check main.dol and StaticR.rel against the clean PAL pins, extract the data partition with nod,
|
||||
* and check the extracted files again before they replace DATA.
|
||||
*
|
||||
* The image is checked before anything is written, so a wrong disc fails in seconds rather than
|
||||
* after the extraction.
|
||||
*/
|
||||
object DiscExtraction {
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val STAGING_DIRECTORY = "DATA.extracting"
|
||||
// Headroom beyond the files themselves: FUSE and directory overhead.
|
||||
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
|
||||
|
||||
/** Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, fd: Int, name: String?, progress: GameSetup.Progress, finishing: () -> Unit): String? {
|
||||
val checks = GameFiles.checks
|
||||
checks.extensionError(name)?.let { return it }
|
||||
|
||||
val header = try {
|
||||
DiscChecks.parseHeader(NodDisc.header(fd))
|
||||
} catch (e: IOException) {
|
||||
return "This disc image could not be read. ${e.message.orEmpty()}".trim()
|
||||
}
|
||||
checks.compatibilityError(header)?.let { return it }
|
||||
checks.revisionError(NodDisc.readFile(fd, DiscChecks.DOL_PATH), NodDisc.readFile(fd, DiscChecks.REL_PATH))
|
||||
?.let { return it }
|
||||
|
||||
val gameRoot = GameStorage.gameRoot(context)
|
||||
gameRoot.mkdirs()
|
||||
val staging = File(gameRoot, STAGING_DIRECTORY)
|
||||
staging.deleteRecursively()
|
||||
try {
|
||||
val needed = header.extractedBytes + FREE_SPACE_MARGIN
|
||||
val available = StatFs(gameRoot.absolutePath).availableBytes
|
||||
if (available < needed) {
|
||||
return "Not enough free space: the game files need ${GameFiles.gigabytes(needed)}, and ${GameFiles.gigabytes(available)} is free."
|
||||
}
|
||||
|
||||
staging.mkdirs()
|
||||
progress.update(0, header.extractedBytes)
|
||||
NodDisc.extract(fd, staging.absolutePath) { done, total -> progress.update(done, total) }
|
||||
|
||||
finishing()
|
||||
GameFiles.validateData(staging)?.let { return it }
|
||||
GameFiles.replace(GameStorage.discDirectory(context), staging)?.let { return it }
|
||||
Log.i(TAG, "Game data extracted into ${GameStorage.discDirectory(context).absolutePath}")
|
||||
return null
|
||||
} finally {
|
||||
// Gone already after a successful run, which moved it to DATA.
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
|
||||
/** Checks and swaps shared by every [GameSetup] task. */
|
||||
object GameFiles {
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
|
||||
val checks = DiscChecks(BuildConfig.DISC_GAME_ID, BuildConfig.DISC_DOL_SHA256, BuildConfig.DISC_REL_SHA256)
|
||||
|
||||
/**
|
||||
* InstallerEngine.ValidateExtractedGame (the PC installer): the files the runtime needs, with
|
||||
* the pinned hashes. Null when [root] is a usable DATA folder.
|
||||
*/
|
||||
fun validateData(root: File): String? {
|
||||
val dol = File(root, DiscChecks.DOL_PATH)
|
||||
val rel = File(root, DiscChecks.REL_PATH)
|
||||
if (!dol.isFile || !rel.isFile || !File(root, DiscChecks.FST_PATH).isFile) {
|
||||
return "The game files are missing required Mario Kart Wii files."
|
||||
}
|
||||
return checks.revisionError(dol.readBytes(), rel.readBytes())
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves [staging] to [destination], keeping the old destination until the new one is in
|
||||
* place. Null on success, otherwise the message to show.
|
||||
*/
|
||||
fun replace(destination: File, staging: File): String? {
|
||||
val replaced = File(destination.parentFile, "${destination.name}.replaced")
|
||||
replaced.deleteRecursively()
|
||||
if (destination.exists() && !destination.renameTo(replaced)) {
|
||||
return "The existing ${destination.name} folder could not be replaced. Remove it, for example with " +
|
||||
"adb shell rm -r ${destination.absolutePath}, and try again."
|
||||
}
|
||||
if (!staging.renameTo(destination)) {
|
||||
replaced.renameTo(destination)
|
||||
return "The new files could not be moved into ${destination.absolutePath}."
|
||||
}
|
||||
// The new copy is in place; an old one that will not delete only costs space.
|
||||
if (replaced.exists() && !replaced.deleteRecursively()) {
|
||||
Log.w(TAG, "Could not remove ${replaced.absolutePath}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun gigabytes(bytes: Long) = "%.1f GB".format(bytes / 1_000_000_000.0)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FilterInputStream
|
||||
import java.io.InputStream
|
||||
import java.io.InterruptedIOException
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipInputStream
|
||||
import org.json.JSONObject
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
* A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest")
|
||||
* becomes this headset's game: libmain.so and game.json, and DATA too when the package carries
|
||||
* the extracted disc.
|
||||
*
|
||||
* The package is only accepted for the disc and app it was built for: game.json must name the
|
||||
* pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
|
||||
* in the package. Both parts are staged and checked before either replaces what is installed.
|
||||
*/
|
||||
object GamePackageImport {
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val SCHEMA = 1
|
||||
private const val DATA_PREFIX = "DATA/"
|
||||
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
|
||||
|
||||
/** Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, descriptor: ParcelFileDescriptor, progress: GameSetup.Progress, finishing: () -> Unit): String? {
|
||||
val total = descriptor.statSize
|
||||
val gameRoot = GameStorage.gameRoot(context)
|
||||
val available = StatFs(gameRoot.absolutePath).availableBytes
|
||||
if (total > 0 && available < total + FREE_SPACE_MARGIN) {
|
||||
return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free."
|
||||
}
|
||||
|
||||
val libraryDir = GameLibrary.directory(context)
|
||||
val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing")
|
||||
val dataStaging = File(gameRoot, "DATA.importing")
|
||||
libraryStaging.deleteRecursively()
|
||||
dataStaging.deleteRecursively()
|
||||
try {
|
||||
libraryStaging.mkdirs()
|
||||
var manifestText: String? = null
|
||||
var librarySha256: String? = null
|
||||
var hasData = false
|
||||
|
||||
val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor))
|
||||
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
var lastReport = 0L
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
val name = entry.name
|
||||
if (entry.isDirectory) continue
|
||||
if (name.startsWith("/") || name.split('/').any { it == ".." }) {
|
||||
return "The game file contains an invalid path: $name"
|
||||
}
|
||||
when {
|
||||
name == GameLibrary.MANIFEST_NAME -> manifestText = zip.readBytes().toString(Charsets.UTF_8)
|
||||
name == GameLibrary.LIBRARY_NAME -> {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
File(libraryStaging, GameLibrary.LIBRARY_NAME).outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
librarySha256 = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
name.startsWith(DATA_PREFIX) -> {
|
||||
hasData = true
|
||||
val target = File(dataStaging, name.removePrefix(DATA_PREFIX))
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (counted.count - lastReport >= 8L shl 20) {
|
||||
lastReport = counted.count
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
finishing()
|
||||
val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?: return "This is not a WiiCompiled game file (game.json is missing)."
|
||||
manifestError(context, manifestJson, librarySha256)?.let { return it }
|
||||
if (hasData) {
|
||||
GameFiles.validateData(dataStaging)?.let { return it }
|
||||
} else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
|
||||
return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first."
|
||||
}
|
||||
|
||||
File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!)
|
||||
if (hasData) {
|
||||
GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it }
|
||||
}
|
||||
libraryDir.parentFile?.mkdirs()
|
||||
GameFiles.replace(libraryDir, libraryStaging)?.let { return it }
|
||||
Log.i(TAG, "Imported the game built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
|
||||
return null
|
||||
} finally {
|
||||
libraryStaging.deleteRecursively()
|
||||
dataStaging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun manifestError(context: Context, manifest: JSONObject, librarySha256: String?): String? {
|
||||
if (manifest.optInt("schema") != SCHEMA) {
|
||||
return "This game file comes from a newer or older launcher (format ${manifest.optInt("schema")}). Build it again with the current PC launcher."
|
||||
}
|
||||
if (manifest.optString("profile") != BuildConfig.PROFILE) {
|
||||
return "This game file is for ${manifest.optString("profile")}, but this app is ${BuildConfig.PROFILE}."
|
||||
}
|
||||
if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) ||
|
||||
!manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) ||
|
||||
!manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true)
|
||||
) {
|
||||
return "This game file was not built from the supported clean PAL Mario Kart Wii (${BuildConfig.DISC_GAME_ID})."
|
||||
}
|
||||
if (manifest.optString("kitFingerprint") != GameLibrary.kitFingerprint(context)) {
|
||||
return "This game file was built for a different version of this app. Build it again against the version installed on the headset."
|
||||
}
|
||||
val expected = manifest.optString("librarySha256")
|
||||
if (librarySha256 == null) {
|
||||
return "This game file has no game library (libmain.so)."
|
||||
}
|
||||
if (!expected.equals(librarySha256, ignoreCase = true)) {
|
||||
return "The game library in this file is damaged (its checksum does not match). Copy the file again."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private class CountingInputStream(input: InputStream) : FilterInputStream(input) {
|
||||
@Volatile
|
||||
var count = 0L
|
||||
private set
|
||||
|
||||
override fun read(): Int = super.read().also { if (it >= 0) count++ }
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, len).also { if (it > 0) count += it }
|
||||
|
||||
override fun skip(n: Long): Long = super.skip(n).also { count += it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* The long-running steps that make the game playable from what the player owns, one at a time,
|
||||
* process-wide. [GameSetupService] runs them in the foreground and the launcher watches [state].
|
||||
*
|
||||
* - [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]).
|
||||
*
|
||||
* 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 }
|
||||
|
||||
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
|
||||
data class Finishing(val task: Task) : State
|
||||
data class Done(val task: Task) : State
|
||||
data class Failed(val task: Task, val message: String) : State
|
||||
data class Cancelled(val task: Task) : State
|
||||
}
|
||||
|
||||
/** What a task step reports; returning false from [update] asks it to stop. */
|
||||
fun interface Progress {
|
||||
fun update(done: Long, total: Long): Boolean
|
||||
}
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val listeners = mutableSetOf<(State) -> Unit>()
|
||||
|
||||
@Volatile
|
||||
var state: State = State.Idle
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var cancelRequested = false
|
||||
|
||||
val isRunning: Boolean
|
||||
get() = state is State.Checking || state is State.Working || state is State.Finishing
|
||||
|
||||
/** Listeners are called on the main thread, with the current state first. */
|
||||
fun addListener(listener: (State) -> Unit) {
|
||||
listeners += listener
|
||||
listener(state)
|
||||
}
|
||||
|
||||
fun removeListener(listener: (State) -> Unit) {
|
||||
listeners -= listener
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancelRequested = true
|
||||
}
|
||||
|
||||
/** Claims the single slot for [task]; false when another task is running. */
|
||||
fun begin(task: Task): Boolean {
|
||||
synchronized(this) {
|
||||
if (isRunning) return false
|
||||
cancelRequested = false
|
||||
publish(State.Checking(task))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. */
|
||||
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)
|
||||
}
|
||||
} ?: "The selected file could not be opened."
|
||||
if (failure != null) {
|
||||
publish(State.Failed(task, failure))
|
||||
return
|
||||
}
|
||||
if (deleteSource && uri.scheme == "file") {
|
||||
uri.path?.let { File(it).delete() }
|
||||
}
|
||||
publish(State.Done(task))
|
||||
} catch (e: InterruptedIOException) {
|
||||
publish(State.Cancelled(task))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "$task failed", e)
|
||||
publish(State.Failed(task, e.message ?: e.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
fun displayName(context: Context, uri: Uri): String? =
|
||||
runCatching {
|
||||
context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment
|
||||
|
||||
private fun publish(next: State) {
|
||||
state = next
|
||||
main.post {
|
||||
for (listener in listeners.toList()) listener(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlin.concurrent.thread
|
||||
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.
|
||||
*/
|
||||
class GameSetupService : Service() {
|
||||
|
||||
private var lastNotified = 0L
|
||||
private val listener: (GameSetup.State) -> Unit = { state ->
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (state !is GameSetup.State.Working || now - lastNotified >= NOTIFY_INTERVAL_MS) {
|
||||
lastNotified = now
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(state))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// startForegroundService obliges the service to go foreground even when it has nothing
|
||||
// to do, or Android 12+ ends the app.
|
||||
createChannel()
|
||||
val task = intent?.getStringExtra(EXTRA_TASK)?.let { name -> GameSetup.Task.entries.firstOrNull { it.name == name } }
|
||||
?: 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)) {
|
||||
// A task already going keeps the service, and ends it itself.
|
||||
if (!GameSetup.isRunning) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf(startId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
GameSetup.addListener(listener)
|
||||
val deleteSource = intent.getBooleanExtra(EXTRA_DELETE_SOURCE, false)
|
||||
|
||||
val wakeLock = getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
|
||||
wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS)
|
||||
thread(name = "GameSetup") {
|
||||
try {
|
||||
GameSetup.run(applicationContext, task, uri, deleteSource)
|
||||
} finally {
|
||||
releaseReadPermission(uri)
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
mainExecutor.execute {
|
||||
GameSetup.removeListener(listener)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun releaseReadPermission(uri: Uri) {
|
||||
if (uri.scheme != "content") return
|
||||
try {
|
||||
contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
} catch (e: SecurityException) {
|
||||
// Never persisted: the provider does not offer persistable grants.
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, getString(R.string.disc_setup_channel), NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notification(state: GameSetup.State): Notification {
|
||||
val builder = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_wheel)
|
||||
.setContentTitle(getString(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
|
||||
builder.setContentText(getString(text, percent)).setProgress(100, percent, false)
|
||||
} else {
|
||||
builder.setContentText(getString(R.string.disc_setup_checking)).setProgress(0, 0, true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val CHANNEL_ID = "disc_setup"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFY_INTERVAL_MS = 1000L
|
||||
private const val EXTRA_TASK = "task"
|
||||
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
|
||||
|
||||
/**
|
||||
* Starts [task] on [uri]: a document the player picked, or a file in the Import folder,
|
||||
* which [deleteSource] removes once it has been imported.
|
||||
*/
|
||||
fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) {
|
||||
if (uri.scheme == "content") {
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
} catch (e: SecurityException) {
|
||||
Log.i(TAG, "No persistable read grant for $uri; relying on the picker's grant")
|
||||
}
|
||||
}
|
||||
val intent = Intent(context, GameSetupService::class.java)
|
||||
.setData(uri)
|
||||
.putExtra(EXTRA_TASK, task.name)
|
||||
.putExtra(EXTRA_DELETE_SOURCE, deleteSource)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.QuestActivity
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR),
|
||||
* with a Home page that sets up and starts the game and a Settings page that edits Config.toml.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* this panel stays behind to relaunch it.
|
||||
*/
|
||||
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 lateinit var navHome: View
|
||||
private lateinit var navSettings: View
|
||||
private lateinit var homePage: View
|
||||
private lateinit var settingsView: View
|
||||
private lateinit var trails: WheelTrailsView
|
||||
private lateinit var playButton: View
|
||||
private lateinit var playIcon: ImageView
|
||||
private lateinit var playText: TextView
|
||||
private lateinit var progress: ProgressBar
|
||||
private lateinit var homeStatus: TextView
|
||||
private lateinit var secondary: TextView
|
||||
private lateinit var cancel: View
|
||||
private lateinit var dataBanner: View
|
||||
private lateinit var dataBannerIcon: ImageView
|
||||
private lateinit var dataBannerText: TextView
|
||||
private lateinit var settings: SettingsPage
|
||||
|
||||
private var page = Page.Home
|
||||
private var launching = false
|
||||
private var trailsAway = true
|
||||
private var setupKind: Class<*>? = null
|
||||
private var mainAction = Action.Play
|
||||
private var secondaryAction: Action? = null
|
||||
|
||||
private val setupListener: (GameSetup.State) -> Unit = { state ->
|
||||
when {
|
||||
page == Page.Home -> refreshHome()
|
||||
// Rows only change when a task starts or ends, not with every progress step.
|
||||
state.javaClass != setupKind -> settings.refresh()
|
||||
}
|
||||
setupKind = state.javaClass
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_launcher)
|
||||
try {
|
||||
GameStorage.prepare(this)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Cannot prepare the game directory", e)
|
||||
}
|
||||
|
||||
navHome = findViewById(R.id.nav_home)
|
||||
navSettings = findViewById(R.id.nav_settings)
|
||||
homePage = findViewById(R.id.page_home)
|
||||
settingsView = findViewById(R.id.page_settings)
|
||||
trails = findViewById(R.id.home_trails)
|
||||
playButton = findViewById(R.id.home_play)
|
||||
playIcon = findViewById(R.id.home_play_icon)
|
||||
playText = findViewById(R.id.home_play_text)
|
||||
progress = findViewById(R.id.home_progress)
|
||||
homeStatus = findViewById(R.id.home_status)
|
||||
secondary = findViewById(R.id.home_secondary)
|
||||
cancel = findViewById(R.id.home_cancel)
|
||||
dataBanner = findViewById(R.id.home_data_banner)
|
||||
dataBannerIcon = findViewById(R.id.home_data_banner_icon)
|
||||
dataBannerText = findViewById(R.id.home_data_banner_text)
|
||||
|
||||
findViewById<TextView>(R.id.home_title).setText(
|
||||
if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base,
|
||||
)
|
||||
findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME)
|
||||
|
||||
settings = SettingsPage(
|
||||
this,
|
||||
settingsView,
|
||||
ConfigStore(GameStorage.configFile(this)),
|
||||
gameRunning = ::isGameRunning,
|
||||
selectDiscImage = ::selectDiscImage,
|
||||
importGame = ::importGame,
|
||||
)
|
||||
savedInstanceState?.getString(KEY_TAB)?.let { name ->
|
||||
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
|
||||
}
|
||||
|
||||
navHome.setOnClickListener { showPage(Page.Home) }
|
||||
navSettings.setOnClickListener { showPage(Page.Settings) }
|
||||
playButton.setOnClickListener { perform(mainAction) }
|
||||
secondary.setOnClickListener { secondaryAction?.let(::perform) }
|
||||
cancel.setOnClickListener { GameSetup.cancel() }
|
||||
|
||||
val restored = savedInstanceState?.getString(KEY_PAGE)?.let { name -> Page.entries.firstOrNull { it.name == name } }
|
||||
showPage(restored ?: Page.Home)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
launching = false
|
||||
importDroppedPackage()
|
||||
setupKind = GameSetup.state.javaClass
|
||||
refresh()
|
||||
GameSetup.addListener(setupListener)
|
||||
// The game's process can take a moment to go away after its activity
|
||||
// closes, which would still read as running.
|
||||
val runningAtResume = isGameRunning()
|
||||
window.decorView.postDelayed({
|
||||
if (!isFinishing && isGameRunning() != runningAtResume) refresh()
|
||||
}, PROCESS_EXIT_GRACE_MS)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
GameSetup.removeListener(setupListener)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString(KEY_PAGE, page.name)
|
||||
outState.putString(KEY_TAB, settings.tab.name)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
val uri = data?.data ?: return
|
||||
if (resultCode != RESULT_OK) return
|
||||
val task = when (requestCode) {
|
||||
REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc
|
||||
REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage
|
||||
else -> return
|
||||
}
|
||||
GameSetupService.start(this, task, uri)
|
||||
// Progress and the outcome are shown on Home, wherever the picker was opened from.
|
||||
showPage(Page.Home)
|
||||
}
|
||||
|
||||
private fun showPage(target: Page) {
|
||||
page = target
|
||||
navHome.isSelected = target == Page.Home
|
||||
navSettings.isSelected = target == Page.Settings
|
||||
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
|
||||
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
|
||||
if (target == Page.Home) {
|
||||
trailsAway = true
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
when (page) {
|
||||
Page.Home -> refreshHome()
|
||||
Page.Settings -> settings.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshHome() {
|
||||
val disc = GameStorage.discDirectory(this).absolutePath
|
||||
val discStatus = GameStorage.discStatus(this)
|
||||
val gameStatus = GameLibrary.status(this)
|
||||
val running = isGameRunning()
|
||||
val setup = GameSetup.state
|
||||
val settingUp = GameSetup.isRunning
|
||||
val importing = setupTask(setup) == GameSetup.Task.ImportPackage
|
||||
|
||||
mainAction = when {
|
||||
running -> Action.Resume
|
||||
gameStatus != GameLibrary.Status.Ready -> Action.ImportGame
|
||||
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
|
||||
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
|
||||
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.Finishing -> getString(R.string.home_finishing)
|
||||
else -> getString(label(mainAction))
|
||||
}
|
||||
secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE
|
||||
secondaryAction?.let { secondary.setText(label(it)) }
|
||||
|
||||
progress.visibility = if (settingUp) View.VISIBLE else View.GONE
|
||||
progress.isIndeterminate = setup !is GameSetup.State.Working
|
||||
if (setup is GameSetup.State.Working && setup.total > 0) {
|
||||
progress.progress = (setup.done * progress.max / setup.total).toInt()
|
||||
}
|
||||
cancel.visibility = if (settingUp && setup !is GameSetup.State.Finishing) View.VISIBLE else View.GONE
|
||||
|
||||
homeStatus.text = when {
|
||||
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)
|
||||
settingUp || setup is GameSetup.State.Failed -> ""
|
||||
setup is GameSetup.State.Cancelled -> getString(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 -> getString(R.string.home_put_on_headset)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
when {
|
||||
settingUp -> showBanner(null)
|
||||
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)
|
||||
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
|
||||
showBanner(getString(R.string.home_setup_intro), warning = false)
|
||||
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
|
||||
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
|
||||
else -> showBanner(null)
|
||||
}
|
||||
|
||||
if (trailsAway && !launching) {
|
||||
trailsAway = false
|
||||
trails.enter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun label(action: Action): Int = when (action) {
|
||||
Action.Play -> R.string.home_play
|
||||
Action.Resume -> R.string.home_resume
|
||||
Action.SelectDisc -> R.string.home_select_disc
|
||||
Action.ImportGame -> R.string.home_import
|
||||
}
|
||||
|
||||
private fun perform(action: Action) {
|
||||
if (GameSetup.isRunning) return
|
||||
when (action) {
|
||||
Action.Play, Action.Resume -> play()
|
||||
Action.SelectDisc -> selectDiscImage()
|
||||
Action.ImportGame -> importGame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBanner(text: String?, warning: Boolean = false) {
|
||||
if (text == null) {
|
||||
dataBanner.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
dataBannerText.text = text
|
||||
dataBanner.setBackgroundResource(if (warning) R.drawable.bg_banner_warning else R.drawable.bg_banner_info)
|
||||
dataBannerIcon.setImageResource(if (warning) R.drawable.ic_warning else R.drawable.ic_disc)
|
||||
dataBannerIcon.imageTintList = ColorStateList.valueOf(getColor(if (warning) R.color.warning_500 else R.color.primary_400))
|
||||
dataBanner.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
/** Opens the document picker for a disc image, asking first when it would replace DATA. */
|
||||
private fun selectDiscImage() {
|
||||
if (GameSetup.isRunning) return
|
||||
if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing) {
|
||||
openPicker(REQUEST_DISC_IMAGE)
|
||||
return
|
||||
}
|
||||
confirm(R.string.home_replace_title, R.string.home_replace_message, R.string.home_select_disc) {
|
||||
openPicker(REQUEST_DISC_IMAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the document picker for a .wcgame, asking first when it would replace the game. */
|
||||
private fun importGame() {
|
||||
if (GameSetup.isRunning) return
|
||||
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
|
||||
openPicker(REQUEST_GAME_PACKAGE)
|
||||
return
|
||||
}
|
||||
confirm(R.string.home_replace_game_title, R.string.home_replace_game_message, R.string.home_import) {
|
||||
openPicker(REQUEST_GAME_PACKAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirm(title: Int, message: Int, positive: Int, onConfirm: () -> Unit) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(positive) { _, _ -> onConfirm() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openPicker(requestCode: Int) {
|
||||
// Disc images and game files have no reliable MIME type, so every file is offered
|
||||
// and the task checks the name and contents.
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("*/*")
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, requestCode)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.w(TAG, "No document picker", e)
|
||||
Toast.makeText(this, R.string.home_no_picker, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the newest .wcgame in the Import folder, where Build-QuestGame.ps1 -Install and adb
|
||||
* put them. Each package is tried once, whether it imports or not, until it changes: a file
|
||||
* adb pushed belongs to the shell user, so the app cannot always delete it afterwards.
|
||||
*/
|
||||
private fun importDroppedPackage() {
|
||||
if (GameSetup.isRunning) return
|
||||
val dropped = GameStorage.importDirectory(this)
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".wcgame", ignoreCase = true) }
|
||||
?.maxByOrNull { it.lastModified() }
|
||||
?: return
|
||||
val identity = "${dropped.absolutePath}:${dropped.length()}:${dropped.lastModified()}"
|
||||
val preferences = getSharedPreferences(PREFERENCES, MODE_PRIVATE)
|
||||
if (preferences.getString(KEY_LAST_DROPPED_IMPORT, null) == identity) return
|
||||
preferences.edit().putString(KEY_LAST_DROPPED_IMPORT, identity).apply()
|
||||
Log.i(TAG, "Importing dropped game package ${dropped.absolutePath}")
|
||||
GameSetupService.start(this, GameSetup.Task.ImportPackage, Uri.fromFile(dropped), deleteSource = true)
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
if (launching) {
|
||||
return
|
||||
}
|
||||
launching = true
|
||||
trailsAway = true
|
||||
trails.leave {
|
||||
try {
|
||||
startActivity(Intent(this, QuestActivity::class.java))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, "Cannot start the game activity", e)
|
||||
Toast.makeText(this, R.string.home_launch_failed, Toast.LENGTH_LONG).show()
|
||||
launching = false
|
||||
refreshHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGameRunning(): Boolean {
|
||||
val manager = getSystemService(ActivityManager::class.java) ?: return false
|
||||
val gameProcess = "$packageName$GAME_PROCESS_SUFFIX"
|
||||
return manager.runningAppProcesses.orEmpty().any { it.processName == gameProcess }
|
||||
}
|
||||
|
||||
private fun setupTask(state: GameSetup.State): GameSetup.Task? = when (state) {
|
||||
is GameSetup.State.Checking -> state.task
|
||||
is GameSetup.State.Working -> state.task
|
||||
is GameSetup.State.Finishing -> state.task
|
||||
is GameSetup.State.Done -> state.task
|
||||
is GameSetup.State.Failed -> state.task
|
||||
is GameSetup.State.Cancelled -> state.task
|
||||
GameSetup.State.Idle -> null
|
||||
}
|
||||
|
||||
private fun percent(state: GameSetup.State.Working): Int =
|
||||
if (state.total > 0) (state.done * 100 / state.total).toInt() else 0
|
||||
|
||||
private companion object {
|
||||
const val TAG = "WiiCompiledLauncher"
|
||||
// Must match QuestActivity's android:process in AndroidManifest.xml.
|
||||
const val GAME_PROCESS_SUFFIX = ":game"
|
||||
const val KEY_PAGE = "page"
|
||||
const val KEY_TAB = "settingsTab"
|
||||
const val PROCESS_EXIT_GRACE_MS = 1000L
|
||||
const val REQUEST_DISC_IMAGE = 1
|
||||
const val REQUEST_GAME_PACKAGE = 2
|
||||
const val PREFERENCES = "launcher"
|
||||
const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* nod, the disc image library the PC installer runs as nodtool, through
|
||||
* android/nod-jni. Every call takes the file descriptor of an open disc image
|
||||
* (ISO, WBFS, RVZ, WIA, CISO, GCZ) and reads the Wii data partition from it.
|
||||
* All three block, so callers run them off the main thread.
|
||||
*/
|
||||
object NodDisc {
|
||||
|
||||
init {
|
||||
System.loadLibrary("nod_jni")
|
||||
}
|
||||
|
||||
fun interface Listener {
|
||||
/** Bytes written so far out of [total]; return false to cancel. */
|
||||
fun update(done: Long, total: Long): Boolean
|
||||
}
|
||||
|
||||
/** "<game id>\n<title>\n<bytes an extraction writes>", see [DiscChecks.parseHeader]. */
|
||||
@Throws(IOException::class)
|
||||
external fun header(fd: Int): String
|
||||
|
||||
/** `sys/main.dol` or a path below `files/` of the data partition. */
|
||||
@Throws(IOException::class)
|
||||
external fun readFile(fd: Int, path: String): ByteArray
|
||||
|
||||
/**
|
||||
* Writes the data partition to [outDir] in nodtool extract's layout.
|
||||
* Throws [InterruptedIOException] when [listener] cancels.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
external fun extract(fd: Int, outDir: String, listener: Listener)
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Spinner
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import kotlin.math.roundToInt
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* The launcher's Settings page: tabs of setting rows, each bound to one or two
|
||||
* Config.toml keys.
|
||||
*
|
||||
* Every row reads its value the way runtime/include/runtime_config.h parses it,
|
||||
* with the same default and the same accepted range, so what is shown is what
|
||||
* the game will use. Nothing is written until the player changes a row, and
|
||||
* each change is one line edit of a fresh read of the file.
|
||||
*/
|
||||
class SettingsPage(
|
||||
private val activity: Activity,
|
||||
root: View,
|
||||
private val store: ConfigStore,
|
||||
private val gameRunning: () -> Boolean,
|
||||
private val selectDiscImage: () -> Unit,
|
||||
private val importGame: () -> Unit,
|
||||
) {
|
||||
|
||||
enum class Tab(val label: Int) {
|
||||
Vr(R.string.settings_tab_vr),
|
||||
Graphics(R.string.settings_tab_graphics),
|
||||
Controls(R.string.settings_tab_controls),
|
||||
Audio(R.string.settings_tab_audio),
|
||||
About(R.string.settings_tab_about),
|
||||
}
|
||||
|
||||
private class Dependent(val row: View, val control: View, val enabledIf: (TomlConfig) -> Boolean)
|
||||
|
||||
private val tabStrip: LinearLayout = root.findViewById(R.id.settings_tabs)
|
||||
private val rows: LinearLayout = root.findViewById(R.id.settings_rows)
|
||||
private val scroll: ScrollView = root.findViewById(R.id.settings_scroll)
|
||||
private val dependents = mutableListOf<Dependent>()
|
||||
private val tabViews = mutableMapOf<Tab, TextView>()
|
||||
|
||||
/** The file as read for the rows on screen. */
|
||||
private var current = TomlConfig.parse("")
|
||||
|
||||
var tab: Tab = Tab.Vr
|
||||
private set
|
||||
|
||||
init {
|
||||
for (entry in Tab.entries) {
|
||||
val view = TextView(activity, null, 0, R.style.Launcher_Tab).apply {
|
||||
setText(entry.label)
|
||||
setOnClickListener { select(entry) }
|
||||
}
|
||||
tabStrip.addView(view, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(44)))
|
||||
tabViews[entry] = view
|
||||
}
|
||||
tabViews.getValue(tab).isSelected = true
|
||||
}
|
||||
|
||||
fun select(entry: Tab) {
|
||||
if (entry != tab) {
|
||||
tabViews.getValue(tab).isSelected = false
|
||||
tab = entry
|
||||
tabViews.getValue(tab).isSelected = true
|
||||
scroll.scrollTo(0, 0)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Rebuilds the open tab from the file, which the headset panel may have changed. */
|
||||
fun refresh() {
|
||||
current = store.load() ?: TomlConfig.parse("")
|
||||
rows.removeAllViews()
|
||||
dependents.clear()
|
||||
if (gameRunning()) {
|
||||
banner(R.drawable.bg_banner_warning, R.string.settings_running_note)
|
||||
} else {
|
||||
banner(R.drawable.bg_banner_info, R.string.settings_apply_note)
|
||||
}
|
||||
when (tab) {
|
||||
Tab.Vr -> buildVr()
|
||||
Tab.Graphics -> buildGraphics()
|
||||
Tab.Controls -> buildControls()
|
||||
Tab.Audio -> buildAudio()
|
||||
Tab.About -> buildAbout()
|
||||
}
|
||||
updateDependents(current)
|
||||
}
|
||||
|
||||
private fun buildVr() {
|
||||
val firstPerson = { c: TomlConfig -> c.bool("vr", "first_person") ?: false }
|
||||
section(R.string.section_vr_camera) {
|
||||
choice(
|
||||
R.string.vr_camera, R.string.vr_camera_helper,
|
||||
listOf(R.string.vr_camera_chase, R.string.vr_camera_first_person),
|
||||
read = { if (firstPerson(it)) 1 else 0 },
|
||||
write = { c, index -> c.setBool("vr", "first_person", index == 1) },
|
||||
)
|
||||
choice(
|
||||
R.string.vr_rotation, R.string.vr_rotation_helper,
|
||||
listOf(R.string.vr_rotation_yaw, R.string.vr_rotation_yaw_pitch, R.string.vr_rotation_full),
|
||||
read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS) },
|
||||
write = { c, index -> c.setString("vr", "first_person_rotation", ROTATIONS[index]) },
|
||||
enabledIf = firstPerson,
|
||||
)
|
||||
// One setting in two keys, presented as the in-headset panel's two
|
||||
// tick boxes are: nothing, the driver (model 0), or every model.
|
||||
choice(
|
||||
R.string.vr_hide, R.string.vr_hide_helper,
|
||||
listOf(R.string.vr_hide_nothing, R.string.vr_hide_driver, R.string.vr_hide_driver_and_kart),
|
||||
read = {
|
||||
val hide = it.bool("vr", "first_person_hide_driver") ?: true
|
||||
val model = it.integer("vr", "first_person_hidden_model")?.takeIf { m -> m in -1L..31L } ?: 0L
|
||||
when {
|
||||
!hide -> 0
|
||||
model < 0 -> 2
|
||||
else -> 1
|
||||
}
|
||||
},
|
||||
write = { c, index ->
|
||||
c.setBool("vr", "first_person_hide_driver", index != 0)
|
||||
if (index != 0) {
|
||||
c.setInteger("vr", "first_person_hidden_model", if (index == 1) 0L else -1L)
|
||||
}
|
||||
},
|
||||
enabledIf = firstPerson,
|
||||
)
|
||||
slider(
|
||||
R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0,
|
||||
read = { number(it, "vr", "lean_back_degrees", -45.0, 45.0, 0.0) },
|
||||
format = { "%.0f°".format(it) },
|
||||
write = { c, value -> c.setFloat("vr", "lean_back_degrees", value) },
|
||||
)
|
||||
}
|
||||
section(R.string.section_vr_headset) {
|
||||
slider(
|
||||
R.string.vr_render_scale, R.string.vr_render_scale_helper, 0.25, 2.0, 0.05,
|
||||
read = { number(it, "vr", "render_scale", 0.25, 2.0, 1.0) },
|
||||
format = { "%.2f×".format(it) },
|
||||
write = { c, value -> c.setFloat("vr", "render_scale", value) },
|
||||
)
|
||||
choice(
|
||||
R.string.vr_interpolation, R.string.vr_interpolation_helper,
|
||||
listOf(activity.getString(R.string.vr_interpolation_off), activity.getString(R.string.vr_interpolation_auto), "72 FPS", "90 FPS", "120 FPS"),
|
||||
read = { INTERPOLATION_FPS.indexOf(vrInterpolationFps(it)) },
|
||||
write = { c, index -> c.setInteger("vr", "frame_interpolation_fps", INTERPOLATION_FPS[index]) },
|
||||
)
|
||||
}
|
||||
section(R.string.section_vr_screen) {
|
||||
toggle(
|
||||
R.string.vr_hud_screen, R.string.vr_hud_screen_helper,
|
||||
read = { it.bool("vr", "hud_virtual_screen") ?: true },
|
||||
write = { c, value -> c.setBool("vr", "hud_virtual_screen", value) },
|
||||
)
|
||||
slider(
|
||||
R.string.vr_hud_distance, R.string.vr_hud_distance_helper, 0.5, 5.0, 0.1,
|
||||
read = { number(it, "vr", "hud_distance_meters", 0.25, 10.0, 2.0) },
|
||||
format = { "%.1f m".format(it) },
|
||||
write = { c, value -> c.setFloat("vr", "hud_distance_meters", value) },
|
||||
)
|
||||
slider(
|
||||
R.string.vr_hud_width, R.string.vr_hud_width_helper, 1.0, 6.0, 0.1,
|
||||
read = { number(it, "vr", "hud_width_meters", 0.25, 20.0, 2.4) },
|
||||
format = { "%.1f m".format(it) },
|
||||
write = { c, value -> c.setFloat("vr", "hud_width_meters", value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildGraphics() {
|
||||
section(R.string.section_graphics) {
|
||||
choice(
|
||||
R.string.graphics_resolution, R.string.graphics_resolution_helper,
|
||||
RESOLUTIONS.map { if (it == 1.0) activity.getString(R.string.graphics_resolution_native) else "${TomlConfig.formatFloat(it).removeSuffix(".0")}x" },
|
||||
read = { RESOLUTIONS.indexOf(resolution(it)) },
|
||||
write = { c, index -> c.setFloat("video", "resolution_multiplier", RESOLUTIONS[index]) },
|
||||
custom = { if (resolution(it) == 0.0) "Auto" else "${TomlConfig.formatFloat(resolution(it)).removeSuffix(".0")}x" },
|
||||
)
|
||||
toggle(
|
||||
R.string.graphics_widescreen, R.string.graphics_widescreen_helper,
|
||||
read = { it.bool("video", "widescreen") ?: true },
|
||||
write = { c, value -> c.setBool("video", "widescreen", value) },
|
||||
)
|
||||
toggle(
|
||||
R.string.graphics_bloom, R.string.graphics_bloom_helper,
|
||||
read = { (disabledPostProcessing(it) and BLOOM_PATH) != 0L },
|
||||
write = { c, value -> c.setInteger("video", "disabled_post_processing_paths", if (value) BLOOM_PATH else 0L) },
|
||||
)
|
||||
toggle(
|
||||
R.string.graphics_skip_unready, R.string.graphics_skip_unready_helper,
|
||||
read = { it.bool("video", "skip_unready_pipelines") ?: true },
|
||||
write = { c, value -> c.setBool("video", "skip_unready_pipelines", value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildControls() {
|
||||
section(R.string.section_controls) {
|
||||
choice(
|
||||
R.string.controls_mode, R.string.controls_mode_helper,
|
||||
listOf(R.string.controls_mode_wii_remote, R.string.controls_mode_gamepad),
|
||||
read = { stringIndex(it, "vr", "controller_mode", CONTROLLER_MODES) },
|
||||
write = { c, index -> c.setString("vr", "controller_mode", CONTROLLER_MODES[index]) },
|
||||
)
|
||||
toggle(
|
||||
R.string.controls_rumble, R.string.controls_rumble_helper,
|
||||
read = { it.bool("controller", "rumble") ?: true },
|
||||
write = { c, value -> c.setBool("controller", "rumble", value) },
|
||||
)
|
||||
}
|
||||
section(R.string.section_controls_mapping) {
|
||||
info(R.string.controls_map_a, activity.getString(R.string.controls_map_a_value))
|
||||
info(R.string.controls_map_b, activity.getString(R.string.controls_map_b_value))
|
||||
info(R.string.controls_map_12, activity.getString(R.string.controls_map_12_value))
|
||||
info(R.string.controls_map_minus_plus, activity.getString(R.string.controls_map_minus_plus_value))
|
||||
info(R.string.controls_map_stick, activity.getString(R.string.controls_map_stick_value))
|
||||
info(R.string.controls_map_z, activity.getString(R.string.controls_map_z_value))
|
||||
info(R.string.controls_map_c, activity.getString(R.string.controls_map_c_value))
|
||||
info(R.string.controls_map_home, activity.getString(R.string.controls_map_home_value))
|
||||
info(R.string.controls_map_pointer, activity.getString(R.string.controls_map_pointer_value))
|
||||
info(R.string.controls_map_panel, activity.getString(R.string.controls_map_panel_value))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAudio() {
|
||||
section(R.string.section_audio) {
|
||||
for ((label, key) in VOLUMES) {
|
||||
slider(
|
||||
label, null, 0.0, 100.0, 1.0,
|
||||
read = { number(it, "audio", key, 0.0, 1.0, 1.0) * 100.0 },
|
||||
format = { "%.0f%%".format(it) },
|
||||
write = { c, value -> c.setFloat("audio", key, value / 100.0) },
|
||||
)
|
||||
}
|
||||
toggle(
|
||||
R.string.audio_muted, R.string.audio_muted_helper,
|
||||
read = { it.bool("audio", "muted") ?: false },
|
||||
write = { c, value -> c.setBool("audio", "muted", value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAbout() {
|
||||
section(R.string.section_about_app) {
|
||||
info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE))
|
||||
}
|
||||
section(R.string.section_about_storage) {
|
||||
val disc = GameStorage.discDirectory(activity).absolutePath
|
||||
val status = when (GameStorage.discStatus(activity)) {
|
||||
GameStorage.DiscStatus.Ready -> R.string.about_game_data_ready
|
||||
GameStorage.DiscStatus.Incomplete -> R.string.about_game_data_incomplete
|
||||
GameStorage.DiscStatus.Missing -> R.string.about_game_data_missing
|
||||
}
|
||||
info(R.string.about_game_data, activity.getString(status, disc), stacked = true)
|
||||
action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) {
|
||||
selectDiscImage()
|
||||
}
|
||||
val manifest = GameLibrary.manifest(activity)
|
||||
val gameText = when (GameLibrary.status(activity)) {
|
||||
GameLibrary.Status.Ready -> manifest?.let {
|
||||
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
|
||||
} ?: activity.getString(R.string.about_game_missing)
|
||||
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
|
||||
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
|
||||
}
|
||||
info(R.string.about_game, gameText, stacked = true)
|
||||
action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) {
|
||||
importGame()
|
||||
}
|
||||
info(R.string.about_config, store.file.absolutePath, stacked = true)
|
||||
info(R.string.about_logs, GameStorage.logsDirectory(activity).absolutePath, stacked = true)
|
||||
}
|
||||
section(R.string.section_about_diagnostics) {
|
||||
toggle(
|
||||
R.string.about_openxr_logging, R.string.about_openxr_logging_helper,
|
||||
read = { it.bool("diagnostics", "openxr_logging") ?: false },
|
||||
write = { c, value -> c.setBool("diagnostics", "openxr_logging", value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Row construction
|
||||
|
||||
private inner class Section {
|
||||
private val config = current
|
||||
val views = mutableListOf<View>()
|
||||
|
||||
fun toggle(
|
||||
title: Int,
|
||||
helper: Int?,
|
||||
read: (TomlConfig) -> Boolean,
|
||||
write: (TomlConfig, Boolean) -> Unit,
|
||||
enabledIf: ((TomlConfig) -> Boolean)? = null,
|
||||
) {
|
||||
val control = Switch(activity).apply {
|
||||
isChecked = read(config)
|
||||
thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300)
|
||||
trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600)
|
||||
setOnCheckedChangeListener { _, checked -> commit { write(it, checked) } }
|
||||
}
|
||||
val row = row(title, helper, control)
|
||||
row.setOnClickListener { if (control.isEnabled) control.toggle() }
|
||||
add(row, control, enabledIf)
|
||||
}
|
||||
|
||||
/** [read] returns the option index, or -1 when the file holds a value no option names. */
|
||||
fun choice(
|
||||
title: Int,
|
||||
helper: Int?,
|
||||
labels: List<Any>,
|
||||
read: (TomlConfig) -> Int,
|
||||
write: (TomlConfig, Int) -> Unit,
|
||||
enabledIf: ((TomlConfig) -> Boolean)? = null,
|
||||
custom: ((TomlConfig) -> String)? = null,
|
||||
) {
|
||||
val names = labels.map { if (it is Int) activity.getString(it) else it.toString() }.toMutableList()
|
||||
var shown = read(config)
|
||||
if (shown < 0) {
|
||||
// A value only a hand edit can set: show it rather than pretend
|
||||
// it is one of the options, and keep it until the player picks one.
|
||||
names += activity.getString(R.string.settings_custom_value, custom?.invoke(config) ?: "?")
|
||||
shown = names.size - 1
|
||||
}
|
||||
val spinner = Spinner(activity, Spinner.MODE_DROPDOWN).apply {
|
||||
background = drawable(R.drawable.bg_dropdown)
|
||||
setPopupBackgroundDrawable(drawable(R.drawable.bg_dropdown_popup))
|
||||
dropDownVerticalOffset = dp(4)
|
||||
adapter = ArrayAdapter(activity, R.layout.item_dropdown, names).apply {
|
||||
setDropDownViewResource(R.layout.item_dropdown_popup)
|
||||
}
|
||||
setSelection(shown, false)
|
||||
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (position == shown || position >= labels.size) {
|
||||
return
|
||||
}
|
||||
shown = position
|
||||
commit { write(it, position) }
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(dp(210), LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
add(row(title, helper, spinner), spinner, enabledIf)
|
||||
}
|
||||
|
||||
fun slider(
|
||||
title: Int,
|
||||
helper: Int?,
|
||||
min: Double,
|
||||
max: Double,
|
||||
step: Double,
|
||||
read: (TomlConfig) -> Double,
|
||||
format: (Double) -> String,
|
||||
write: (TomlConfig, Double) -> Unit,
|
||||
enabledIf: ((TomlConfig) -> Boolean)? = null,
|
||||
) {
|
||||
val initial = read(config)
|
||||
var committed = initial
|
||||
val valueText = TextView(activity).apply {
|
||||
text = format(initial)
|
||||
setTextColor(activity.getColor(R.color.neutral_100))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
|
||||
gravity = Gravity.END or Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(dp(64), LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
val steps = ((max - min) / step).roundToInt()
|
||||
val seekBar = SeekBar(activity).apply {
|
||||
this.max = steps
|
||||
progress = ((initial.coerceIn(min, max) - min) / step).roundToInt()
|
||||
progressTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_400))
|
||||
thumbTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_300))
|
||||
progressBackgroundTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_600))
|
||||
layoutParams = LinearLayout.LayoutParams(dp(210), LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
// Rounded so 0.25 + 15 * 0.05 is 1.0 again and an untouched value is not rewritten.
|
||||
fun valueAt(progress: Int) = (min + progress * step).let { Math.round(it * 1e6) / 1e6 }
|
||||
fun commitValue(value: Double) {
|
||||
if (value != committed) {
|
||||
committed = value
|
||||
commit { write(it, value) }
|
||||
}
|
||||
}
|
||||
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
private var tracking = false
|
||||
|
||||
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
if (!fromUser) return
|
||||
valueText.text = format(valueAt(progress))
|
||||
// Dragging writes once, on release; a key or controller step writes at once.
|
||||
if (!tracking) commitValue(valueAt(progress))
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(bar: SeekBar) {
|
||||
tracking = true
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(bar: SeekBar) {
|
||||
tracking = false
|
||||
commitValue(valueAt(bar.progress))
|
||||
}
|
||||
})
|
||||
val control = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
addView(seekBar)
|
||||
addView(valueText)
|
||||
}
|
||||
add(row(title, helper, control), seekBar, enabledIf)
|
||||
}
|
||||
|
||||
fun action(title: Int, helper: Int?, button: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
val control = TextView(activity).apply {
|
||||
setText(button)
|
||||
setTextColor(activity.getColor(R.color.neutral_100))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
|
||||
gravity = Gravity.CENTER
|
||||
background = drawable(R.drawable.bg_button_secondary)
|
||||
setPadding(dp(16), 0, dp(16), 0)
|
||||
minHeight = dp(40)
|
||||
isEnabled = enabled
|
||||
alpha = if (enabled) 1f else 0.45f
|
||||
setOnClickListener { onClick() }
|
||||
}
|
||||
add(row(title, helper, control), null, null)
|
||||
}
|
||||
|
||||
/** A read-only line; [stacked] puts a long value such as a path under its title. */
|
||||
fun info(title: Int, value: String, stacked: Boolean = false) {
|
||||
val row: LinearLayout
|
||||
if (stacked) {
|
||||
row = row(title, null, null)
|
||||
val texts = row.getChildAt(0) as LinearLayout
|
||||
texts.addView(helperText(value, selectable = true))
|
||||
} else {
|
||||
val valueText = TextView(activity).apply {
|
||||
text = value
|
||||
setTextColor(activity.getColor(R.color.neutral_300))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
|
||||
}
|
||||
row = row(title, null, valueText)
|
||||
row.minimumHeight = dp(44)
|
||||
}
|
||||
add(row, null, null)
|
||||
}
|
||||
|
||||
private fun add(row: View, control: View?, enabledIf: ((TomlConfig) -> Boolean)?) {
|
||||
views += row
|
||||
if (control != null && enabledIf != null) {
|
||||
dependents += Dependent(row, control, enabledIf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun section(label: Int, build: Section.() -> Unit) {
|
||||
rows.addView(
|
||||
TextView(activity, null, 0, R.style.Launcher_SectionLabel).apply {
|
||||
setText(label)
|
||||
},
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = dp(18)
|
||||
bottomMargin = dp(6)
|
||||
},
|
||||
)
|
||||
val section = Section().apply(build)
|
||||
section.views.forEachIndexed { index, view ->
|
||||
val last = section.views.size - 1
|
||||
view.background = drawable(
|
||||
when {
|
||||
last == 0 -> R.drawable.bg_row_single
|
||||
index == 0 -> R.drawable.bg_row_top
|
||||
index == last -> R.drawable.bg_row_bottom
|
||||
else -> R.drawable.bg_row_middle
|
||||
},
|
||||
)
|
||||
rows.addView(
|
||||
view,
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
if (index > 0) topMargin = dp(3)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun row(title: Int, helper: Int?, control: View?): LinearLayout {
|
||||
val texts = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(
|
||||
TextView(activity).apply {
|
||||
setText(title)
|
||||
setTextColor(activity.getColor(R.color.neutral_100))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
|
||||
},
|
||||
)
|
||||
if (helper != null) {
|
||||
addView(helperText(activity.getString(helper), selectable = false))
|
||||
}
|
||||
}
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
minimumHeight = dp(58)
|
||||
setPadding(dp(16), dp(10), dp(14), dp(10))
|
||||
addView(texts, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { marginEnd = dp(16) })
|
||||
if (control != null) {
|
||||
addView(control)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun helperText(text: String, selectable: Boolean) = TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(activity.getColor(R.color.neutral_400))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f)
|
||||
setTextIsSelectable(selectable)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = dp(2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun banner(background: Int, text: Int) {
|
||||
val warning = background == R.drawable.bg_banner_warning
|
||||
val content = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
this.background = drawable(background)
|
||||
setPadding(dp(12), dp(10), dp(14), dp(10))
|
||||
if (warning) {
|
||||
addView(
|
||||
ImageView(activity).apply {
|
||||
setImageResource(R.drawable.ic_warning)
|
||||
imageTintList = ColorStateList.valueOf(activity.getColor(R.color.warning_500))
|
||||
},
|
||||
LinearLayout.LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(12) },
|
||||
)
|
||||
}
|
||||
addView(
|
||||
TextView(activity).apply {
|
||||
setText(text)
|
||||
setTextColor(activity.getColor(R.color.neutral_300))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
},
|
||||
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f),
|
||||
)
|
||||
}
|
||||
rows.addView(content)
|
||||
}
|
||||
|
||||
private fun commit(edit: (TomlConfig) -> Unit) {
|
||||
var edited: TomlConfig? = null
|
||||
val saved = store.update {
|
||||
edit(it)
|
||||
edited = it
|
||||
}
|
||||
if (!saved) {
|
||||
Toast.makeText(activity, R.string.settings_write_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
edited?.let(::updateDependents)
|
||||
}
|
||||
|
||||
private fun updateDependents(config: TomlConfig) {
|
||||
for (dependent in dependents) {
|
||||
val enabled = dependent.enabledIf(config)
|
||||
dependent.control.isEnabled = enabled
|
||||
dependent.row.alpha = if (enabled) 1f else 0.45f
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkedColors(checked: Int, unchecked: Int) = ColorStateList(
|
||||
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
|
||||
intArrayOf(activity.getColor(checked), activity.getColor(unchecked)),
|
||||
)
|
||||
|
||||
private fun drawable(id: Int): Drawable = activity.getDrawable(id)!!
|
||||
|
||||
private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt()
|
||||
|
||||
private companion object {
|
||||
val ROTATIONS = listOf("yaw", "yaw_pitch", "full")
|
||||
val CONTROLLER_MODES = listOf("wii_remote", "gamepad")
|
||||
val INTERPOLATION_FPS = listOf(0L, 1L, 72L, 90L, 120L)
|
||||
val RESOLUTIONS = listOf(1.0, 1.5, 2.0, 3.0, 4.0)
|
||||
val SUPPORTED_RESOLUTIONS = listOf(0.0, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0)
|
||||
const val BLOOM_PATH = 0x10L
|
||||
const val UINT32_MAX = 0xFFFF_FFFFL
|
||||
val VOLUMES = listOf(
|
||||
R.string.audio_master to "volume",
|
||||
R.string.audio_music to "music_volume",
|
||||
R.string.audio_sound_effects to "sound_effects_volume",
|
||||
R.string.audio_voices to "voices_volume",
|
||||
R.string.audio_ui to "ui_volume",
|
||||
)
|
||||
|
||||
/** A value outside what the runtime accepts reads as the runtime's default. */
|
||||
fun number(config: TomlConfig, section: String, key: String, min: Double, max: Double, default: Double): Double =
|
||||
config.number(section, key)?.takeIf { it in min..max } ?: default
|
||||
|
||||
/** Unrecognised strings fall back to the first (default) option, as in the runtime. */
|
||||
fun stringIndex(config: TomlConfig, section: String, key: String, values: List<String>): Int =
|
||||
values.indexOf(config.string(section, key)).coerceAtLeast(0)
|
||||
|
||||
fun resolution(config: TomlConfig): Double =
|
||||
config.number("video", "resolution_multiplier")?.takeIf { it in SUPPORTED_RESOLUTIONS } ?: 1.0
|
||||
|
||||
/** RuntimeUserConfig's disabledPostProcessingPaths: only the bloom bit is accepted. */
|
||||
fun disabledPostProcessing(config: TomlConfig): Long =
|
||||
config.integer("video", "disabled_post_processing_paths")
|
||||
?.takeIf { it in 0L..UINT32_MAX && (it and BLOOM_PATH.inv()) == 0L } ?: BLOOM_PATH
|
||||
|
||||
/** vr.frame_interpolation_fps, with the legacy frame_interpolation switch and NormalizeFrameInterpolationFps. */
|
||||
fun vrInterpolationFps(config: TomlConfig): Long {
|
||||
val value = config.integer("vr", "frame_interpolation_fps")?.takeIf { it in 0L..UINT32_MAX }
|
||||
?: config.bool("vr", "frame_interpolation")?.let { if (it) 1L else 0L }
|
||||
?: 0L
|
||||
return if (value in INTERPOLATION_FPS) value else 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The runtime's Config.toml held as lines, read and edited one flat
|
||||
* `key = value` at a time so every comment, unknown key and path the runtime,
|
||||
* the in-headset settings panel or the player put there survives.
|
||||
*
|
||||
* Edits follow RuntimeConfigFile::WriteSetting (runtime/include/runtime_config.h)
|
||||
* line for line: a present key is replaced where it stands, a missing key goes
|
||||
* after its section's last non-blank line, and a missing section is appended.
|
||||
* The typed readers accept only what the runtime's toml11 lookups accept, so a
|
||||
* value the game would ignore reads as absent here too and shows its default.
|
||||
*/
|
||||
class TomlConfig private constructor(private val lines: MutableList<String>) {
|
||||
|
||||
private class Section(val header: Int, val end: Int)
|
||||
|
||||
fun text(): String = if (lines.isEmpty()) "" else lines.joinToString("\n", postfix = "\n")
|
||||
|
||||
/** The value of [section].[key] as written, without a trailing comment. */
|
||||
fun literal(section: String, key: String): String? {
|
||||
val found = findSection(section) ?: return null
|
||||
val index = findKey(found, key) ?: return null
|
||||
val code = removeComment(lines[index])
|
||||
return code.substring(code.indexOf('=') + 1).trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun bool(section: String, key: String): Boolean? = when (literal(section, key)) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** An integer literal; the runtime's integer keys reject floats. */
|
||||
fun integer(section: String, key: String): Long? {
|
||||
val literal = literal(section, key) ?: return null
|
||||
return if (INTEGER.matches(literal)) literal.replace("_", "").toLongOrNull() else null
|
||||
}
|
||||
|
||||
/** An integer or float literal, as FindConfigFloat accepts either. */
|
||||
fun number(section: String, key: String): Double? {
|
||||
val literal = literal(section, key) ?: return null
|
||||
if (!INTEGER.matches(literal) && !FLOAT.matches(literal)) {
|
||||
return null
|
||||
}
|
||||
return literal.replace("_", "").toDoubleOrNull()?.takeIf { it.isFinite() }
|
||||
}
|
||||
|
||||
fun string(section: String, key: String): String? = literal(section, key)?.let(::unquote)
|
||||
|
||||
fun set(section: String, key: String, literal: String) {
|
||||
val replacement = "$key = $literal"
|
||||
val found = findSection(section)
|
||||
if (found == null) {
|
||||
if (lines.isNotEmpty() && lines.last().isNotEmpty()) {
|
||||
lines.add("")
|
||||
}
|
||||
lines.add("[$section]")
|
||||
lines.add(replacement)
|
||||
return
|
||||
}
|
||||
val index = findKey(found, key)
|
||||
if (index != null) {
|
||||
lines[index] = replacement
|
||||
return
|
||||
}
|
||||
// Keep the key above the blank line that separates this section from
|
||||
// the next header, where a reader would take it for the next section's.
|
||||
var insertAt = found.end
|
||||
while (insertAt > found.header + 1 && lines[insertAt - 1].trim().isEmpty()) {
|
||||
insertAt--
|
||||
}
|
||||
lines.add(insertAt, replacement)
|
||||
}
|
||||
|
||||
fun setBool(section: String, key: String, value: Boolean) = set(section, key, value.toString())
|
||||
|
||||
fun setInteger(section: String, key: String, value: Long) = set(section, key, value.toString())
|
||||
|
||||
fun setFloat(section: String, key: String, value: Double) = set(section, key, formatFloat(value))
|
||||
|
||||
fun setString(section: String, key: String, value: String) = set(section, key, quote(value))
|
||||
|
||||
private fun findSection(section: String): Section? {
|
||||
var header = -1
|
||||
for (i in lines.indices) {
|
||||
val name = headerName(lines[i]) ?: continue
|
||||
if (header >= 0) {
|
||||
return Section(header, i)
|
||||
}
|
||||
if (name == section) {
|
||||
header = i
|
||||
}
|
||||
}
|
||||
return if (header >= 0) Section(header, lines.size) else null
|
||||
}
|
||||
|
||||
private fun findKey(section: Section, key: String): Int? {
|
||||
for (i in section.header + 1 until section.end) {
|
||||
val code = removeComment(lines[i]).trim()
|
||||
val equals = code.indexOf('=')
|
||||
if (equals >= 0 && code.substring(0, equals).trim() == key) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val INTEGER = Regex("[+-]?\\d(_?\\d)*")
|
||||
private val FLOAT = Regex("[+-]?\\d(_?\\d)*(\\.\\d(_?\\d)*)?([eE][+-]?\\d(_?\\d)*)?")
|
||||
|
||||
fun parse(text: String): TomlConfig {
|
||||
val lines = text.split('\n').map { it.removeSuffix("\r") }.toMutableList()
|
||||
if (lines.last().isEmpty()) {
|
||||
lines.removeAt(lines.size - 1)
|
||||
}
|
||||
return TomlConfig(lines)
|
||||
}
|
||||
|
||||
/** The line up to a `#` that is not inside a quoted string. */
|
||||
fun removeComment(line: String): String {
|
||||
var inSingle = false
|
||||
var inDouble = false
|
||||
var escaped = false
|
||||
for (i in line.indices) {
|
||||
val ch = line[i]
|
||||
if (inDouble && ch == '\\' && !escaped) {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
when {
|
||||
ch == '\'' && !inDouble -> inSingle = !inSingle
|
||||
ch == '"' && !inSingle && !escaped -> inDouble = !inDouble
|
||||
ch == '#' && !inSingle && !inDouble -> return line.substring(0, i)
|
||||
}
|
||||
escaped = false
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
private fun headerName(line: String): String? {
|
||||
val code = removeComment(line).trim()
|
||||
if (code.length < 2 || code.first() != '[' || code.last() != ']') {
|
||||
return null
|
||||
}
|
||||
return code.substring(1, code.length - 1).trim()
|
||||
}
|
||||
|
||||
/** Three decimals at most, and always a decimal point so TOML reads a float. */
|
||||
fun formatFloat(value: Double): String {
|
||||
val text = BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
return if (text.contains('.')) text else "$text.0"
|
||||
}
|
||||
|
||||
fun quote(value: String): String = buildString {
|
||||
append('"')
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'"' -> append("\\\"")
|
||||
'\\' -> append("\\\\")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> if (ch < ' ' || ch == '') append(String.format(Locale.ROOT, "\\u%04x", ch.code)) else append(ch)
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
|
||||
/** A TOML basic or literal string's content, or null for anything else. */
|
||||
fun unquote(literal: String): String? {
|
||||
if (literal.length >= 2 && literal.first() == '\'' && literal.last() == '\'') {
|
||||
val content = literal.substring(1, literal.length - 1)
|
||||
return if (content.contains('\'')) null else content
|
||||
}
|
||||
if (literal.length < 2 || literal.first() != '"' || literal.last() != '"') {
|
||||
return null
|
||||
}
|
||||
val result = StringBuilder()
|
||||
var i = 1
|
||||
val end = literal.length - 1
|
||||
while (i < end) {
|
||||
val ch = literal[i]
|
||||
if (ch == '"') {
|
||||
return null
|
||||
}
|
||||
if (ch != '\\') {
|
||||
result.append(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (i + 1 >= end) {
|
||||
return null
|
||||
}
|
||||
when (val escape = literal[i + 1]) {
|
||||
'b' -> result.append('\b')
|
||||
't' -> result.append('\t')
|
||||
'n' -> result.append('\n')
|
||||
'f' -> result.append('')
|
||||
'r' -> result.append('\r')
|
||||
'"' -> result.append('"')
|
||||
'\\' -> result.append('\\')
|
||||
'u', 'U' -> {
|
||||
val digits = if (escape == 'u') 4 else 8
|
||||
if (i + 2 + digits > end) {
|
||||
return null
|
||||
}
|
||||
val code = literal.substring(i + 2, i + 2 + digits).toIntOrNull(16) ?: return null
|
||||
if (!Character.isValidCodePoint(code)) {
|
||||
return null
|
||||
}
|
||||
result.appendCodePoint(code)
|
||||
i += digits
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* The Home page's backdrop: WheelWizard's wheel trails, broad diagonal bands
|
||||
* that each end in a wheel. They roll in when the page opens and roll away
|
||||
* when Play is pressed.
|
||||
*
|
||||
* A trail is anchored at its wheel and its band runs from there off the edge.
|
||||
* Rolling moves the wheel along the band, so an entering or leaving trail
|
||||
* always comes from or goes to the edge it is attached to.
|
||||
*/
|
||||
class WheelTrailsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : View(context, attrs) {
|
||||
|
||||
private class Trail(
|
||||
val fromRight: Boolean,
|
||||
val xDp: Float,
|
||||
val fromBottom: Boolean,
|
||||
val yDp: Float,
|
||||
/** Clockwise from straight down, the direction the band runs. */
|
||||
val angle: Float,
|
||||
val band: Int,
|
||||
val wheel: Int,
|
||||
)
|
||||
|
||||
private val trails = listOf(
|
||||
Trail(false, 30f, false, -20f, 45f, R.color.primary_400, R.color.primary_700),
|
||||
Trail(false, 210f, false, 10f, 45f, R.color.primary_600, R.color.primary_800),
|
||||
Trail(false, 230f, false, 210f, 45f, R.color.primary_200, R.color.primary_600),
|
||||
Trail(true, 230f, true, 60f, -35f, R.color.primary_400, R.color.primary_700),
|
||||
Trail(true, 40f, true, 10f, -35f, R.color.primary_600, R.color.primary_800),
|
||||
)
|
||||
|
||||
private val density = resources.displayMetrics.density
|
||||
private val bandWidth = 137f * density
|
||||
private val bandLength = 3000f * density
|
||||
private val bandPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val wheelPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rect = RectF()
|
||||
|
||||
/** How far every wheel sits from its anchor along its band, in pixels. */
|
||||
private var offset = 0f
|
||||
private var rotationDegrees = 0f
|
||||
private var animator: ValueAnimator? = null
|
||||
|
||||
/** Rolls the trails in from the edges. */
|
||||
fun enter() {
|
||||
animate(from = 320f * density, to = 0f, spin = 560f, durationMs = 650L, leaving = false, done = null)
|
||||
}
|
||||
|
||||
/** Rolls the trails off the edges, then runs [done]. */
|
||||
fun leave(done: () -> Unit) {
|
||||
animate(from = offset, to = 1400f * density, spin = 360f, durationMs = 450L, leaving = true, done = done)
|
||||
}
|
||||
|
||||
private fun animate(from: Float, to: Float, spin: Float, durationMs: Long, leaving: Boolean, done: (() -> Unit)?) {
|
||||
animator?.cancel()
|
||||
val startRotation = if (leaving) rotationDegrees else -200f
|
||||
animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = durationMs
|
||||
interpolator = if (leaving) AccelerateInterpolator(1.6f) else DecelerateInterpolator(2f)
|
||||
addUpdateListener {
|
||||
val t = it.animatedValue as Float
|
||||
offset = from + (to - from) * t
|
||||
rotationDegrees = startRotation + spin * t
|
||||
invalidate()
|
||||
}
|
||||
if (done != null) {
|
||||
addListener(object : android.animation.AnimatorListenerAdapter() {
|
||||
private var cancelled = false
|
||||
override fun onAnimationCancel(animation: android.animation.Animator) {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: android.animation.Animator) {
|
||||
if (!cancelled) done()
|
||||
}
|
||||
})
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
animator?.cancel()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
for (trail in trails) {
|
||||
val x = if (trail.fromRight) width - trail.xDp * density else trail.xDp * density
|
||||
val y = if (trail.fromBottom) height - trail.yDp * density else trail.yDp * density
|
||||
canvas.save()
|
||||
canvas.translate(x, y)
|
||||
canvas.rotate(trail.angle)
|
||||
canvas.translate(0f, offset)
|
||||
drawTrail(canvas, trail)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawTrail(canvas: Canvas, trail: Trail) {
|
||||
val half = bandWidth / 2f
|
||||
bandPaint.color = context.getColor(trail.band)
|
||||
wheelPaint.color = context.getColor(trail.wheel)
|
||||
|
||||
rect.set(-half, -half, half, bandLength)
|
||||
canvas.drawRoundRect(rect, half, half, bandPaint)
|
||||
|
||||
// Tyre, rim and hub, the same wheel as ic_wheel at band scale.
|
||||
canvas.rotate(rotationDegrees)
|
||||
val unit = bandWidth / 24f * 0.85f
|
||||
canvas.drawCircle(0f, 0f, 11f * unit, wheelPaint)
|
||||
canvas.drawCircle(0f, 0f, 8f * unit, bandPaint)
|
||||
canvas.drawCircle(0f, 0f, 6.5f * unit, wheelPaint)
|
||||
for (spoke in 0 until 5) {
|
||||
val radians = Math.toRadians(-90.0 + 72.0 * spoke)
|
||||
canvas.drawCircle(
|
||||
(3.9f * unit * Math.cos(radians)).toFloat(),
|
||||
(3.9f * unit * Math.sin(radians)).toFloat(),
|
||||
1.45f * unit,
|
||||
bandPaint,
|
||||
)
|
||||
}
|
||||
canvas.drawCircle(0f, 0f, 0.9f * unit, bandPaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/primary_400" android:state_selected="true" />
|
||||
<item android:color="@color/neutral_50" android:state_hovered="true" />
|
||||
<item android:color="@color/neutral_300" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/neutral_400" android:state_enabled="false" />
|
||||
<item android:color="@color/neutral_50" />
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/neutral_50" android:state_selected="true" />
|
||||
<item android:color="@color/neutral_200" android:state_hovered="true" />
|
||||
<item android:color="@color/neutral_400" />
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_700" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<stroke android:width="1dp" android:color="@color/warning_500" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_600" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_950" />
|
||||
<corners android:topLeftRadius="14dp" android:bottomLeftRadius="14dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<selector>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
</item>
|
||||
<item
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:end="10dp"
|
||||
android:drawable="@drawable/ic_chevron_down"
|
||||
android:gravity="end|center_vertical" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<stroke android:width="1dp" android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The tall card behind the game title: a half-circle top on a 250dp width. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<corners android:topLeftRadius="125dp" android:topRightRadius="125dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Sidebar entry: the open page takes the content's colour, as in WheelWizard's menu. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_950" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="@android:color/transparent" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<stroke android:width="1dp" android:color="@color/neutral_600" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary_800" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary_600" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary_700" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<corners android:bottomLeftRadius="8dp" android:bottomRightRadius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_900" />
|
||||
<corners android:topLeftRadius="8dp" android:topRightRadius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Settings tab: an underline marks the open tab. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true">
|
||||
<layer-list>
|
||||
<item android:gravity="bottom" android:height="3dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary_400" />
|
||||
<corners android:radius="2dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<layer-list>
|
||||
<item android:gravity="bottom" android:height="3dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_600" />
|
||||
<corners android:radius="2dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:drawable="@android:color/transparent" />
|
||||
</selector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:strokeColor="@color/neutral_300"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2.5"
|
||||
android:pathData="M6,9L12,15L18,9" />
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- An optical disc with its centre hole and a read-side highlight. Drawn for this app. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M1.5,12a10.5,10.5 0 1,0 21,0a10.5,10.5 0 1,0 -21,0z
|
||||
M9.5,12a2.5,2.5 0 1,0 5,0a2.5,2.5 0 1,0 -5,0z
|
||||
M12,4.5A7.5,7.5 0 0,0 4.5,12H6A6,6 0 0,1 12,6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.5"
|
||||
android:pathData="M12,3L2.5,11.5H5V20.5H10V15H14V20.5H19V11.5H21.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2.5"
|
||||
android:pathData="M7,4.5L19.5,12L7,19.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M3.5,6H20.5M3.5,12H20.5M3.5,18H20.5" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12.4,6a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z
|
||||
M5.9,12a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z
|
||||
M10.4,18a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12,2.5L22.5,20.5H1.5Z M11,9H13V15H11Z M11,16.5H13V18.5H11Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A kart wheel: tyre, rim with five holes, hub. Drawn for this app. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M1,12a11,11 0 1,0 22,0a11,11 0 1,0 -22,0z M4,12a8,8 0 1,0 16,0a8,8 0 1,0 -16,0z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5.5,12a6.5,6.5 0 1,0 13,0a6.5,6.5 0 1,0 -13,0z
|
||||
M10.55,8.1a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
|
||||
M14.26,10.8a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
|
||||
M12.84,15.16a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
|
||||
M8.26,15.16a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
|
||||
M6.84,10.8a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z
|
||||
M11.1,12a0.9,0.9 0 1,0 1.8,0a0.9,0.9 0 1,0 -1.8,0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The launcher panel: WheelWizard's layout, a sidebar of pages beside the open page. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/neutral_900"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="210dp"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_wheel"
|
||||
android:tint="@color/neutral_400" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/launcher_section_general" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_home"
|
||||
style="@style/Launcher.NavItem">
|
||||
|
||||
<ImageView
|
||||
style="@style/Launcher.NavIcon"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.NavLabel"
|
||||
android:text="@string/launcher_nav_home" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_settings"
|
||||
style="@style/Launcher.NavItem">
|
||||
|
||||
<ImageView
|
||||
style="@style/Launcher.NavIcon"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_sliders" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.NavLabel"
|
||||
android:text="@string/launcher_nav_settings" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/launcher_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="@drawable/bg_pill"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_content"
|
||||
android:clipToOutline="true">
|
||||
|
||||
<include
|
||||
android:id="@+id/page_home"
|
||||
layout="@layout/page_home" />
|
||||
|
||||
<include
|
||||
android:id="@+id/page_settings"
|
||||
layout="@layout/page_settings"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="34dp"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="14sp" />
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/bg_nav_item"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="14sp" />
|
||||
@@ -0,0 +1,203 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="64dp"
|
||||
android:gravity="bottom"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/launcher_nav_home" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/home_data_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_banner_warning"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/home_data_banner_icon"
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_warning"
|
||||
android:tint="@color/warning_500" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_data_banner_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="-24dp"
|
||||
android:layout_marginEnd="-24dp"
|
||||
android:layout_weight="1"
|
||||
android:clipChildren="true">
|
||||
|
||||
<org.wiicompiled.quest.launcher.WheelTrailsView
|
||||
android:id="@+id/home_trails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="250dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_home_card"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="10dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_marginTop="25dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_wheel"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="24sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/home_backend"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/home_play"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="22dp"
|
||||
android:background="@drawable/bg_play_button"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:minWidth="190dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/home_play_icon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:duplicateParentState="true"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_play"
|
||||
android:tint="@color/play_button_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_play_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:duplicateParentState="true"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/home_play"
|
||||
android:textColor="@color/play_button_content"
|
||||
android:textSize="17sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_secondary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_button_secondary"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:minWidth="190dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:textColor="@color/neutral_100"
|
||||
android:textSize="15sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/home_progress"
|
||||
style="@android:style/Widget.Material.ProgressBar.Horizontal"
|
||||
android:layout_width="190dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:max="1000"
|
||||
android:progressBackgroundTint="@color/neutral_700"
|
||||
android:progressTint="@color/primary_400"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center"
|
||||
android:minLines="2"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:background="@drawable/bg_nav_item"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:text="@string/home_cancel"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/launcher_nav_settings" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/settings_tabs"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="-14dp"
|
||||
android:orientation="horizontal" />
|
||||
</HorizontalScrollView>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/settings_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fadeScrollbars="false"
|
||||
android:scrollbarStyle="outsideOverlay">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/settings_rows"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="28dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -1,4 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A237E</color>
|
||||
|
||||
<!-- The launcher uses WheelWizard's palette (Views/Styles/Resources/Constants.axaml)
|
||||
so the Quest and PC launchers read as the same product. -->
|
||||
<color name="neutral_50">#F6F7F9</color>
|
||||
<color name="neutral_100">#EDEDF1</color>
|
||||
<color name="neutral_200">#D7D9E0</color>
|
||||
<color name="neutral_300">#B4B8C5</color>
|
||||
<color name="neutral_400">#8B91A5</color>
|
||||
<color name="neutral_500">#6C7389</color>
|
||||
<color name="neutral_600">#575D72</color>
|
||||
<color name="neutral_700">#474B5D</color>
|
||||
<color name="neutral_800">#3E424E</color>
|
||||
<color name="neutral_900">#363944</color>
|
||||
<color name="neutral_950">#24252D</color>
|
||||
|
||||
<color name="primary_200">#8EFFE2</color>
|
||||
<color name="primary_300">#34EAC5</color>
|
||||
<color name="primary_400">#01CBA5</color>
|
||||
<color name="primary_600">#00A488</color>
|
||||
<color name="primary_700">#03826E</color>
|
||||
<color name="primary_800">#08675A</color>
|
||||
|
||||
<color name="warning_500">#FFC800</color>
|
||||
<color name="warning_600">#D19200</color>
|
||||
<color name="danger_500">#FE2048</color>
|
||||
</resources>
|
||||
@@ -3,4 +3,172 @@
|
||||
<string name="app_name">WiiCompiled VR</string>
|
||||
<string name="missing_game_data_title">Game data not found</string>
|
||||
<string name="missing_game_data_message">Copy the extracted DATA directory of your own PAL RMCP01 Mario Kart Wii disc to:\n\n%1$s\n\nFor example with adb: adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\n\nThen start the app again.</string>
|
||||
|
||||
<!-- Launcher shell -->
|
||||
<string name="launcher_section_general">General</string>
|
||||
<string name="launcher_nav_home">Home</string>
|
||||
<string name="launcher_nav_settings">Settings</string>
|
||||
<string name="launcher_version">v%1$s</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="home_title_base">Mario Kart Wii</string>
|
||||
<string name="home_title_retro_rewind">Retro Rewind</string>
|
||||
<string name="home_backend">WiiCompiled OpenXR VR</string>
|
||||
<string name="home_play">Play</string>
|
||||
<string name="home_resume">Resume</string>
|
||||
<string name="home_select_disc">Select disc image</string>
|
||||
<string name="home_running">The game is still running in the background.</string>
|
||||
<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_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>
|
||||
<string name="home_replace_game_title">Replace the game?</string>
|
||||
<string name="home_replace_game_message">The game installed now stays until the new game file has been checked and completely imported.</string>
|
||||
<string name="home_data_incomplete">DATA is there but incomplete or unreadable. Select your disc image to extract it again, or copy the whole extracted partition (sys/ and files/, not just files/) to:\n%1$s</string>
|
||||
<string name="home_checking">Checking disc…</string>
|
||||
<string name="home_extracting">Extracting %1$d%%</string>
|
||||
<string name="home_finishing">Finishing…</string>
|
||||
<string name="home_extract_progress">%1$s of %2$s</string>
|
||||
<string name="home_checking_status">Reading the disc image and checking it is the clean PAL game.</string>
|
||||
<string name="home_cancel">Cancel</string>
|
||||
<string name="home_setup_done">The game files are ready.</string>
|
||||
<string name="home_setup_cancelled">Extraction cancelled.</string>
|
||||
<string name="home_setup_failed">The game files could not be set up.\n%1$s</string>
|
||||
<string name="home_no_picker">This headset has no file picker to choose a disc image with.</string>
|
||||
<string name="home_replace_title">Replace the game files?</string>
|
||||
<string name="home_replace_message">Your current DATA folder stays until the new disc image has been checked and completely extracted.</string>
|
||||
<string name="home_launch_failed">The game could not be started.</string>
|
||||
|
||||
<!-- Settings shell -->
|
||||
<string name="settings_tab_vr">VR</string>
|
||||
<string name="settings_tab_graphics">Graphics</string>
|
||||
<string name="settings_tab_controls">Controls</string>
|
||||
<string name="settings_tab_audio">Audio</string>
|
||||
<string name="settings_tab_about">About</string>
|
||||
<string name="settings_apply_note">Changes apply the next time the game starts. While playing, click both thumbsticks together to open the in-headset settings.</string>
|
||||
<string name="settings_running_note">The game is running, so these changes wait until it is closed and started again.</string>
|
||||
<string name="settings_write_failed">Could not save Config.toml.</string>
|
||||
<string name="settings_custom_value">Custom (%1$s)</string>
|
||||
|
||||
<!-- VR tab -->
|
||||
<string name="section_vr_camera">Camera</string>
|
||||
<string name="vr_camera">Camera</string>
|
||||
<string name="vr_camera_helper">Ride behind the kart like the game, or sit in the driver\'s seat.</string>
|
||||
<string name="vr_camera_chase">Chase camera</string>
|
||||
<string name="vr_camera_first_person">First person</string>
|
||||
<string name="vr_rotation">Follow kart rotation</string>
|
||||
<string name="vr_rotation_helper">In first person, follow turning only (the comfortable choice), turning and climbing, or all kart rotation including banking.</string>
|
||||
<string name="vr_rotation_yaw">Turning only</string>
|
||||
<string name="vr_rotation_yaw_pitch">Turning + pitch</string>
|
||||
<string name="vr_rotation_full">Full rotation</string>
|
||||
<string name="vr_hide">Hide in first person</string>
|
||||
<string name="vr_hide_helper">Your driver sits where your eyes are. Other racers are never hidden.</string>
|
||||
<string name="vr_hide_nothing">Nothing</string>
|
||||
<string name="vr_hide_driver">Driver</string>
|
||||
<string name="vr_hide_driver_and_kart">Driver and kart</string>
|
||||
<string name="vr_lean_back">Lean back angle</string>
|
||||
<string name="vr_lean_back_helper">Tilts the race view back for playing reclined. 0 applies no tilt.</string>
|
||||
|
||||
<string name="section_vr_headset">Headset</string>
|
||||
<string name="vr_render_scale">Headset render scale</string>
|
||||
<string name="vr_render_scale_helper">Scales the eye resolution the headset recommends. Higher is sharper and costs GPU time; lower it if races stutter.</string>
|
||||
<string name="vr_interpolation">VR frame interpolation</string>
|
||||
<string name="vr_interpolation_helper">Experimental. Renders extra frames between game frames at the headset\'s rate. Needs GPU headroom and adds one game frame of latency.</string>
|
||||
<string name="vr_interpolation_off">Off</string>
|
||||
<string name="vr_interpolation_auto">Auto</string>
|
||||
|
||||
<string name="section_vr_screen">Virtual screen</string>
|
||||
<string name="vr_hud_screen">Race HUD on the virtual screen</string>
|
||||
<string name="vr_hud_screen_helper">Puts the minimap, position and item roulette on a screen in front of you instead of stretching them across the view.</string>
|
||||
<string name="vr_hud_distance">Screen distance</string>
|
||||
<string name="vr_hud_distance_helper">How far the menu screen and race HUD sit in front of you.</string>
|
||||
<string name="vr_hud_width">Screen width</string>
|
||||
<string name="vr_hud_width_helper">How wide the menu screen and race HUD are.</string>
|
||||
|
||||
<!-- Graphics tab -->
|
||||
<string name="section_graphics">Rendering</string>
|
||||
<string name="graphics_resolution">Internal resolution</string>
|
||||
<string name="graphics_resolution_helper">The game\'s own render size. Native keeps the headset at its best frame rate.</string>
|
||||
<string name="graphics_resolution_native">Native (1x)</string>
|
||||
<string name="graphics_widescreen">Widescreen</string>
|
||||
<string name="graphics_widescreen_helper">16:9 menus and races instead of 4:3.</string>
|
||||
<string name="graphics_bloom">Disable bloom</string>
|
||||
<string name="graphics_bloom_helper">Bloom\'s bright glow reads poorly in a headset, so it starts off.</string>
|
||||
<string name="graphics_skip_unready">Prevent shader stutters</string>
|
||||
<string name="graphics_skip_unready_helper">Skips a draw for a moment while its shader compiles instead of pausing the game.</string>
|
||||
|
||||
<!-- Controls tab -->
|
||||
<string name="section_controls">Controllers</string>
|
||||
<string name="controls_mode">VR controllers</string>
|
||||
<string name="controls_mode_helper">What the Touch controllers are to the game.</string>
|
||||
<string name="controls_mode_wii_remote">Wii Remote + Nunchuk</string>
|
||||
<string name="controls_mode_gamepad">Gamepad</string>
|
||||
<string name="controls_rumble">Vibration</string>
|
||||
<string name="controls_rumble_helper">The game\'s rumble vibrates the controllers.</string>
|
||||
<string name="section_controls_mapping">Wii Remote + Nunchuk buttons</string>
|
||||
<string name="controls_map_a">A</string>
|
||||
<string name="controls_map_a_value">Right A</string>
|
||||
<string name="controls_map_b">B</string>
|
||||
<string name="controls_map_b_value">Right trigger</string>
|
||||
<string name="controls_map_12">1 / 2</string>
|
||||
<string name="controls_map_12_value">Right stick up / down</string>
|
||||
<string name="controls_map_minus_plus">− / +</string>
|
||||
<string name="controls_map_minus_plus_value">Right stick left / right</string>
|
||||
<string name="controls_map_stick">Nunchuk stick</string>
|
||||
<string name="controls_map_stick_value">Left stick</string>
|
||||
<string name="controls_map_z">Z</string>
|
||||
<string name="controls_map_z_value">Left trigger</string>
|
||||
<string name="controls_map_c">C</string>
|
||||
<string name="controls_map_c_value">Left grip</string>
|
||||
<string name="controls_map_home">HOME</string>
|
||||
<string name="controls_map_home_value">Left menu</string>
|
||||
<string name="controls_map_pointer">Pointer and motion</string>
|
||||
<string name="controls_map_pointer_value">Aim and move the right controller</string>
|
||||
<string name="controls_map_panel">Settings panel</string>
|
||||
<string name="controls_map_panel_value">Click both thumbsticks</string>
|
||||
|
||||
<!-- Audio tab -->
|
||||
<string name="section_audio">Volume</string>
|
||||
<string name="audio_master">Master</string>
|
||||
<string name="audio_music">Music</string>
|
||||
<string name="audio_sound_effects">Sound effects</string>
|
||||
<string name="audio_voices">Voices</string>
|
||||
<string name="audio_ui">Menu sounds</string>
|
||||
<string name="audio_muted">Mute</string>
|
||||
<string name="audio_muted_helper">Silences the game without changing the volumes above.</string>
|
||||
|
||||
<!-- About tab -->
|
||||
<string name="section_about_app">App</string>
|
||||
<string name="about_version">Version</string>
|
||||
<string name="about_version_value">%1$s (%2$s)</string>
|
||||
<string name="section_about_storage">Files</string>
|
||||
<string name="about_game_data">Game data</string>
|
||||
<string name="about_game_data_ready">Found: %1$s</string>
|
||||
<string name="about_game_data_incomplete">Incomplete or unreadable: %1$s</string>
|
||||
<string name="about_game_data_missing">Missing: %1$s</string>
|
||||
<string name="about_config">Configuration</string>
|
||||
<string name="about_logs">Logs</string>
|
||||
<string name="about_extract">Extract from disc image</string>
|
||||
<string name="about_extract_helper">Replaces DATA with the files of your own PAL disc image.</string>
|
||||
<string name="about_game">Game</string>
|
||||
<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_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>
|
||||
<string name="about_openxr_logging_helper">Writes VR frame timing to the run log once per second: late, skipped and black frames, tracking loss. Turn it on to report stutter, and off again afterwards.</string>
|
||||
|
||||
<!-- Disc setup notification -->
|
||||
<string name="disc_setup_channel">Game file setup</string>
|
||||
<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_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>
|
||||
@@ -6,4 +6,81 @@
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
|
||||
<!-- The 2D launcher panel: dark Material widgets tinted with the launcher palette. -->
|
||||
<style name="Theme.WiiCompiled.Launcher" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/neutral_900</item>
|
||||
<item name="android:statusBarColor">@color/neutral_900</item>
|
||||
<item name="android:navigationBarColor">@color/neutral_900</item>
|
||||
<item name="android:colorPrimary">@color/neutral_900</item>
|
||||
<item name="android:colorAccent">@color/primary_400</item>
|
||||
<item name="android:colorControlNormal">@color/neutral_400</item>
|
||||
<item name="android:textColorPrimary">@color/neutral_100</item>
|
||||
<item name="android:textColorSecondary">@color/neutral_400</item>
|
||||
<item name="android:colorBackground">@color/neutral_950</item>
|
||||
<item name="android:alertDialogTheme">@style/Theme.WiiCompiled.Launcher.Dialog</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.WiiCompiled.Launcher.Dialog" parent="android:Theme.Material.Dialog.Alert">
|
||||
<item name="android:colorAccent">@color/primary_400</item>
|
||||
<item name="android:colorBackground">@color/neutral_900</item>
|
||||
</style>
|
||||
|
||||
<!-- Namespace for the launcher's view styles below. -->
|
||||
<style name="Launcher" />
|
||||
|
||||
<style name="Launcher.PageTitle">
|
||||
<item name="android:textColor">@color/neutral_300</item>
|
||||
<item name="android:textSize">26sp</item>
|
||||
<item name="android:fontFamily">sans-serif-medium</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.SectionLabel">
|
||||
<item name="android:textColor">@color/neutral_300</item>
|
||||
<item name="android:textSize">14sp</item>
|
||||
<item name="android:fontFamily">sans-serif-medium</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.NavItem">
|
||||
<item name="android:layout_width">match_parent</item>
|
||||
<item name="android:layout_height">48dp</item>
|
||||
<item name="android:orientation">horizontal</item>
|
||||
<item name="android:gravity">center_vertical</item>
|
||||
<item name="android:paddingStart">12dp</item>
|
||||
<item name="android:paddingEnd">12dp</item>
|
||||
<item name="android:layout_marginBottom">2dp</item>
|
||||
<item name="android:background">@drawable/bg_nav_item</item>
|
||||
<item name="android:clickable">true</item>
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.NavIcon">
|
||||
<item name="android:layout_width">24dp</item>
|
||||
<item name="android:layout_height">24dp</item>
|
||||
<item name="android:tint">@color/nav_content</item>
|
||||
<item name="android:duplicateParentState">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.NavLabel">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_marginStart">14dp</item>
|
||||
<item name="android:textColor">@color/nav_content</item>
|
||||
<item name="android:textSize">19sp</item>
|
||||
<item name="android:duplicateParentState">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.Tab">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">44dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:paddingStart">14dp</item>
|
||||
<item name="android:paddingEnd">14dp</item>
|
||||
<item name="android:textSize">15sp</item>
|
||||
<item name="android:fontFamily">sans-serif-medium</item>
|
||||
<item name="android:textColor">@color/tab_text</item>
|
||||
<item name="android:background">@drawable/bg_tab</item>
|
||||
<item name="android:clickable">true</item>
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** The Quest launcher must accept and reject disc images exactly as the PC installer does. */
|
||||
class DiscChecksTest {
|
||||
|
||||
private val dol = "clean dol".toByteArray()
|
||||
private val rel = "clean rel".toByteArray()
|
||||
private val checks = DiscChecks("RMCP01", DiscChecks.sha256(dol), DiscChecks.sha256(rel))
|
||||
|
||||
@Test
|
||||
fun acceptsTheFormatsNodReads() {
|
||||
for (name in listOf("mkw.iso", "MKW.ISO", "game.rvz", "game.wbfs", "a.b.wia", "x.ciso", "x.gcz", "x.gcm")) {
|
||||
assertNull(name, checks.extensionError(name))
|
||||
}
|
||||
for (name in listOf("mkw.zip", "DATA", "mkw.iso.part", null)) {
|
||||
assertNotNull(name, checks.extensionError(name))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTheNativeHeader() {
|
||||
val header = DiscChecks.parseHeader("RMCP01\nMARIO KART Wii\n2891234567")
|
||||
assertEquals(DiscChecks.Header("RMCP01", "MARIO KART Wii", 2_891_234_567L), header)
|
||||
assertEquals(0L, DiscChecks.parseHeader("RMCP01").extractedBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsOtherRegionsWithTheInstallerMessage() {
|
||||
assertNull(checks.compatibilityError(DiscChecks.Header("rmcp01", "MARIO KART Wii", 1)))
|
||||
assertEquals(
|
||||
"This build supports Mario Kart Wii PAL (RMCP01). The selected image is RMCE01 (MARIO KART Wii, NTSC-U).",
|
||||
checks.compatibilityError(DiscChecks.Header("RMCE01", "MARIO KART Wii", 1)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requiresTheCleanRevision() {
|
||||
assertNull(checks.revisionError(dol, rel))
|
||||
assertNotNull(checks.revisionError("patched".toByteArray(), rel))
|
||||
assertNotNull(checks.revisionError(dol, "patched".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashesAsLowercaseHex() {
|
||||
assertEquals(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
DiscChecks.sha256("abc".toByteArray()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The launcher and the runtime edit the same Config.toml, so the line editor
|
||||
* must place keys exactly where RuntimeConfigFile::WriteSetting does and read
|
||||
* only what the runtime's toml11 lookups accept.
|
||||
*/
|
||||
class TomlConfigTest {
|
||||
|
||||
private val sample = """
|
||||
# WiiCompiled Quest configuration.
|
||||
[paths]
|
||||
dvd_root = "/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA"
|
||||
|
||||
[video]
|
||||
widescreen = true # set by the activity
|
||||
resolution_multiplier = 1.0
|
||||
|
||||
[vr]
|
||||
enabled = true
|
||||
render_scale = 1.0
|
||||
""".trimIndent() + "\n"
|
||||
|
||||
@Test
|
||||
fun untouchedFileRoundTrips() {
|
||||
assertEquals(sample, TomlConfig.parse(sample).text())
|
||||
val crlf = sample.replace("\n", "\r\n")
|
||||
assertEquals(sample, TomlConfig.parse(crlf).text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacesAKeyInPlace() {
|
||||
val config = TomlConfig.parse(sample)
|
||||
config.setFloat("vr", "render_scale", 0.75)
|
||||
assertEquals(sample.replace("render_scale = 1.0", "render_scale = 0.75"), config.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertsAMissingKeyAboveTheBlankLineBeforeTheNextSection() {
|
||||
val config = TomlConfig.parse(sample)
|
||||
config.setBool("video", "skip_unready_pipelines", false)
|
||||
val expected = sample.replace(
|
||||
"resolution_multiplier = 1.0\n\n[vr]",
|
||||
"resolution_multiplier = 1.0\nskip_unready_pipelines = false\n\n[vr]",
|
||||
)
|
||||
assertEquals(expected, config.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendsAMissingSection() {
|
||||
val config = TomlConfig.parse(sample)
|
||||
config.setFloat("audio", "volume", 0.5)
|
||||
assertEquals("$sample\n[audio]\nvolume = 0.5\n", config.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAFileFromNothing() {
|
||||
val config = TomlConfig.parse("")
|
||||
config.setString("vr", "controller_mode", "gamepad")
|
||||
assertEquals("[vr]\ncontroller_mode = \"gamepad\"\n", config.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findsKeysOnlyInTheirOwnSection() {
|
||||
val config = TomlConfig.parse("[video]\nenabled = false\n\n[vr]\nenabled = true\n")
|
||||
assertEquals(false, config.bool("video", "enabled"))
|
||||
assertEquals(true, config.bool("vr", "enabled"))
|
||||
assertNull(config.bool("audio", "enabled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresCommentsButNotHashesInsideStrings() {
|
||||
val config = TomlConfig.parse(
|
||||
"[paths] # headers may carry comments\n" +
|
||||
"dvd_root = \"/data/#1/DATA\" # a comment\n" +
|
||||
"# enabled = true\n",
|
||||
)
|
||||
assertEquals("/data/#1/DATA", config.string("paths", "dvd_root"))
|
||||
assertNull(config.literal("paths", "enabled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsNumbersTheWayTheRuntimeDoes() {
|
||||
val config = TomlConfig.parse(
|
||||
"[vr]\n" +
|
||||
"render_scale = 1\n" +
|
||||
"hud_width_meters = 2.4\n" +
|
||||
"lean_back_degrees = -1_0.5\n" +
|
||||
"frame_interpolation_fps = 72\n" +
|
||||
"first_person_hidden_model = 72.0\n" +
|
||||
"world_units_per_meter = 1.0f\n" +
|
||||
"hud_distance_meters = \"2.0\"\n",
|
||||
)
|
||||
// FindConfigFloat accepts integers as well as floats.
|
||||
assertEquals(1.0, config.number("vr", "render_scale")!!, 0.0)
|
||||
assertEquals(2.4, config.number("vr", "hud_width_meters")!!, 0.0)
|
||||
assertEquals(-10.5, config.number("vr", "lean_back_degrees")!!, 0.0)
|
||||
// Integer keys reject floats; anything that is not a TOML number is absent.
|
||||
assertEquals(72L, config.integer("vr", "frame_interpolation_fps"))
|
||||
assertNull(config.integer("vr", "first_person_hidden_model"))
|
||||
assertNull(config.number("vr", "world_units_per_meter"))
|
||||
assertNull(config.number("vr", "hud_distance_meters"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatsFloatsSoTomlReadsAFloat() {
|
||||
assertEquals("1.0", TomlConfig.formatFloat(1.0))
|
||||
assertEquals("0.05", TomlConfig.formatFloat(0.05))
|
||||
assertEquals("1.0", TomlConfig.formatFloat(0.25 + 15 * 0.05))
|
||||
assertEquals("-12.0", TomlConfig.formatFloat(-12.0))
|
||||
assertEquals("0.0", TomlConfig.formatFloat(-0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quotesAndUnquotesStrings() {
|
||||
val value = "C:\\Games\\\"Kart\"\ttab"
|
||||
assertEquals(value, TomlConfig.unquote(TomlConfig.quote(value)))
|
||||
assertEquals("yaw", TomlConfig.unquote("'yaw'"))
|
||||
assertEquals("é", TomlConfig.unquote("\"\\u00e9\""))
|
||||
assertNull(TomlConfig.unquote("yaw"))
|
||||
assertNull(TomlConfig.unquote("\"bad\\q\""))
|
||||
}
|
||||
}
|
||||
Generated
+817
@@ -0,0 +1,817 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "higher-kinded-types"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e690f8474c6c5d8ff99656fcbc195a215acc3949481a8b0b3351c838972dc776"
|
||||
dependencies = [
|
||||
"macro_rules_attribute",
|
||||
"never-say-never",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "liblzma-sys"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c7e3581f367a78d7b7e7ae948d023310556f0cfc13156c2e4e00e25616492b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macro_rules_attribute"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
|
||||
dependencies = [
|
||||
"macro_rules_attribute-proc_macro",
|
||||
"pastey",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macro_rules_attribute-proc_macro"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "never-say-never"
|
||||
version = "6.6.666"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf5a574dadd7941adeaa71823ecba5e28331b8313fb2e1c6a5c7e5981ea53ad6"
|
||||
|
||||
[[package]]
|
||||
name = "nod"
|
||||
version = "2.0.0-alpha.10"
|
||||
source = "git+https://github.com/encounter/nod?tag=v2.0.0-alpha.10#dc18d2ff129f05228b8510ea092d8b24c290a49a"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"aes",
|
||||
"base16ct",
|
||||
"block-padding",
|
||||
"bytes",
|
||||
"bzip2-sys",
|
||||
"cbc",
|
||||
"crc32fast",
|
||||
"crossbeam-channel",
|
||||
"crossbeam-utils",
|
||||
"crypto-common",
|
||||
"digest",
|
||||
"dyn-clone",
|
||||
"encoding_rs",
|
||||
"itertools",
|
||||
"liblzma-sys",
|
||||
"libz-sys",
|
||||
"lru",
|
||||
"md-5",
|
||||
"polonius-the-crab",
|
||||
"sha1",
|
||||
"simple_moving_average",
|
||||
"thiserror 2.0.20",
|
||||
"tracing",
|
||||
"xxhash-rust",
|
||||
"zerocopy",
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nod-jni"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"nod",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "polonius-the-crab"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec242d7eccbb2fd8b3b5b6e3cf89f94a91a800f469005b44d154359609f8af72"
|
||||
dependencies = [
|
||||
"higher-kinded-types",
|
||||
"never-say-never",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simple_moving_average"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a4b144ad185430cd033299e2c93e465d5a7e65fbb858593dc57181fa13cd310"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "nod-jni"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "GPL-3.0-only"
|
||||
publish = false
|
||||
description = "JNI bridge from the Quest launcher to nod, the disc image library behind the PC installer's nodtool."
|
||||
|
||||
[lib]
|
||||
name = "nod_jni"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# Built on its own, never as part of an outer workspace.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
jni = "0.21"
|
||||
# The same nod release as Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs, so the
|
||||
# Quest and PC installers extract a disc identically. alpha.10 is only published on GitHub.
|
||||
nod = { git = "https://github.com/encounter/nod", tag = "v2.0.0-alpha.10", default-features = false, features = [
|
||||
"compress-bzip2-static",
|
||||
"compress-lzma-static",
|
||||
"compress-zlib-static",
|
||||
"compress-zstd-static",
|
||||
"threading",
|
||||
] }
|
||||
zerocopy = "0.8"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
strip = true
|
||||
@@ -0,0 +1,300 @@
|
||||
//! JNI bridge from the Quest launcher (`org.wiicompiled.quest.launcher.NodDisc`) to nod.
|
||||
//!
|
||||
//! The PC installer validates and extracts the player's disc with nodtool. This library gives
|
||||
//! the Quest launcher the same three operations on a file descriptor from Android's document
|
||||
//! picker: read the disc header, read one file of the data partition (so the launcher can hash
|
||||
//! main.dol and StaticR.rel before a long extraction), and extract the data partition.
|
||||
//!
|
||||
//! The extraction is nodtool's `extract` command (nodtool/src/cmd/extract.rs at
|
||||
//! v2.0.0-alpha.10, MIT OR Apache-2.0) with progress reporting and cancellation added, so both
|
||||
//! installers write the same `sys/`, `files/`, `disc/` and `*.bin` layout that the runtime's DVD
|
||||
//! layer reads.
|
||||
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{self, BufRead, Read, Write},
|
||||
os::{
|
||||
fd::{BorrowedFd, RawFd},
|
||||
unix::fs::FileExt,
|
||||
},
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use jni::{
|
||||
JNIEnv,
|
||||
objects::{JObject, JString, JValue},
|
||||
sys::{jbyteArray, jint, jstring},
|
||||
};
|
||||
use nod::{
|
||||
common::PartitionKind,
|
||||
disc::fst::Fst,
|
||||
read::{DiscOptions, DiscReader, DiscStream, PartitionMeta, PartitionOptions, PartitionReader},
|
||||
};
|
||||
use zerocopy::IntoBytes;
|
||||
|
||||
/// Thrown instead of IOException when the listener asked to stop.
|
||||
const CANCELLED_EXCEPTION: &str = "java/io/InterruptedIOException";
|
||||
const PROGRESS_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
enum Failure {
|
||||
Error(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl<E: std::fmt::Display> From<E> for Failure {
|
||||
fn from(error: E) -> Self { Failure::Error(error.to_string()) }
|
||||
}
|
||||
|
||||
type Outcome<T> = Result<T, Failure>;
|
||||
|
||||
/// A disc image read with pread through its own duplicate of the picker's descriptor. pread
|
||||
/// keeps no file position, so nod's preloader threads can each hold a clone.
|
||||
#[derive(Clone)]
|
||||
struct FdStream {
|
||||
file: Arc<File>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl DiscStream for FdStream {
|
||||
fn read_exact_at(&mut self, buf: &mut [u8], offset: u64) -> io::Result<()> {
|
||||
self.file.read_exact_at(buf, offset)
|
||||
}
|
||||
|
||||
fn stream_len(&mut self) -> io::Result<u64> { Ok(self.len) }
|
||||
}
|
||||
|
||||
fn open_disc(fd: RawFd) -> Outcome<DiscReader> {
|
||||
// SAFETY: the caller keeps the ParcelFileDescriptor open for the duration of the JNI call,
|
||||
// and only a duplicate outlives this borrow.
|
||||
let owned = unsafe { BorrowedFd::borrow_raw(fd) }.try_clone_to_owned()?;
|
||||
let file = File::from(owned);
|
||||
let len = file.metadata()?.len();
|
||||
let options = DiscOptions { preloader_threads: 4, ..Default::default() };
|
||||
Ok(DiscReader::new_stream(Box::new(FdStream { file: Arc::new(file), len }), &options)?)
|
||||
}
|
||||
|
||||
fn open_data_partition(disc: &DiscReader) -> Outcome<Box<dyn PartitionReader>> {
|
||||
Ok(disc.open_partition_kind(PartitionKind::Data, &PartitionOptions::default())?)
|
||||
}
|
||||
|
||||
/// Bytes an extraction writes: the system files plus every file in the FST.
|
||||
fn extracted_size(disc: &DiscReader, meta: &PartitionMeta) -> Outcome<u64> {
|
||||
let mut total = (meta.raw_boot.len()
|
||||
+ meta.raw_bi2.len()
|
||||
+ meta.raw_apploader.len()
|
||||
+ meta.raw_fst.len()
|
||||
+ meta.raw_dol.len()) as u64;
|
||||
if disc.header().is_wii() {
|
||||
total += 0x100 + disc.region().map_or(0, |region| region.len()) as u64;
|
||||
for part in [&meta.raw_ticket, &meta.raw_tmd, &meta.raw_cert_chain] {
|
||||
total += part.as_ref().map_or(0, |bytes| bytes.len()) as u64;
|
||||
}
|
||||
total += meta.raw_h3_table.as_ref().map_or(0, |bytes| bytes.len()) as u64;
|
||||
}
|
||||
let fst = Fst::new(&meta.raw_fst)?;
|
||||
for (_, node, _) in fst.iter() {
|
||||
if node.is_file() {
|
||||
total += u64::from(node.length());
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// "<game id>\n<title>\n<bytes an extraction writes>"
|
||||
fn header(fd: RawFd) -> Outcome<String> {
|
||||
let disc = open_disc(fd)?;
|
||||
let mut partition = open_data_partition(&disc)?;
|
||||
let meta = partition.meta()?;
|
||||
let total = extracted_size(&disc, &meta)?;
|
||||
let header = disc.header();
|
||||
Ok(format!("{}\n{}\n{}", header.game_id_str(), header.game_title_str(), total))
|
||||
}
|
||||
|
||||
/// `sys/main.dol`, or a path below `files/`, of the data partition.
|
||||
fn read_file(fd: RawFd, path: &str) -> Outcome<Vec<u8>> {
|
||||
let disc = open_disc(fd)?;
|
||||
let mut partition = open_data_partition(&disc)?;
|
||||
let meta = partition.meta()?;
|
||||
if path == "sys/main.dol" {
|
||||
return Ok(meta.raw_dol.to_vec());
|
||||
}
|
||||
let Some(relative) = path.strip_prefix("files/") else {
|
||||
return Err(Failure::Error(format!("Unsupported disc path {path}")));
|
||||
};
|
||||
let fst = Fst::new(&meta.raw_fst)?;
|
||||
let Some((_, node)) = fst.find(relative) else {
|
||||
return Err(Failure::Error(format!("{path} is not on this disc")));
|
||||
};
|
||||
let mut bytes = Vec::with_capacity(node.length() as usize);
|
||||
partition.open_file(node)?.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
struct Progress<'a> {
|
||||
done: u64,
|
||||
total: u64,
|
||||
last: Instant,
|
||||
report: &'a mut dyn FnMut(u64, u64) -> Outcome<bool>,
|
||||
}
|
||||
|
||||
impl Progress<'_> {
|
||||
fn advance(&mut self, bytes: usize) -> Outcome<()> {
|
||||
self.done += bytes as u64;
|
||||
if self.last.elapsed() >= PROGRESS_INTERVAL {
|
||||
self.last = Instant::now();
|
||||
self.check()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check(&mut self) -> Outcome<()> {
|
||||
if (self.report)(self.done, self.total)? { Ok(()) } else { Err(Failure::Cancelled) }
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(fd: RawFd, out_dir: &Path, report: &mut dyn FnMut(u64, u64) -> Outcome<bool>) -> Outcome<()> {
|
||||
let disc = open_disc(fd)?;
|
||||
let mut partition = open_data_partition(&disc)?;
|
||||
let meta = partition.meta()?;
|
||||
let total = extracted_size(&disc, &meta)?;
|
||||
let mut progress = Progress { done: 0, total, last: Instant::now(), report };
|
||||
progress.check()?;
|
||||
|
||||
extract_sys_files(&disc, &meta, out_dir, &mut progress)?;
|
||||
|
||||
let files_dir = out_dir.join("files");
|
||||
fs::create_dir_all(&files_dir)?;
|
||||
let is_wii = disc.header().is_wii();
|
||||
let fst = Fst::new(&meta.raw_fst)?;
|
||||
for (_, node, path) in fst.iter() {
|
||||
let target = files_dir.join(&path);
|
||||
if node.is_dir() {
|
||||
fs::create_dir_all(&target).map_err(|e| format!("Creating directory {path}: {e}"))?;
|
||||
continue;
|
||||
}
|
||||
let mut file = File::create(&target).map_err(|e| format!("Creating file {path}: {e}"))?;
|
||||
let mut reader = partition.open_file(node).map_err(|e| {
|
||||
format!("Opening {path} on the disc (offset {}, size {}): {e}", node.offset(is_wii), node.length())
|
||||
})?;
|
||||
loop {
|
||||
let buf = reader.fill_buf().map_err(|e| format!("Reading {path}: {e}"))?;
|
||||
let len = buf.len();
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(buf).map_err(|e| format!("Writing {path}: {e}"))?;
|
||||
reader.consume(len);
|
||||
progress.advance(len)?;
|
||||
}
|
||||
file.flush().map_err(|e| format!("Writing {path}: {e}"))?;
|
||||
}
|
||||
progress.check()
|
||||
}
|
||||
|
||||
fn extract_sys_files(disc: &DiscReader, meta: &PartitionMeta, out_dir: &Path, progress: &mut Progress) -> Outcome<()> {
|
||||
let sys_dir = out_dir.join("sys");
|
||||
fs::create_dir_all(&sys_dir)?;
|
||||
write_file(meta.raw_boot.as_ref(), &sys_dir.join("boot.bin"), progress)?;
|
||||
write_file(meta.raw_bi2.as_ref(), &sys_dir.join("bi2.bin"), progress)?;
|
||||
write_file(meta.raw_apploader.as_ref(), &sys_dir.join("apploader.img"), progress)?;
|
||||
write_file(meta.raw_fst.as_ref(), &sys_dir.join("fst.bin"), progress)?;
|
||||
write_file(meta.raw_dol.as_ref(), &sys_dir.join("main.dol"), progress)?;
|
||||
|
||||
let header = disc.header();
|
||||
if header.is_wii() {
|
||||
let disc_dir = out_dir.join("disc");
|
||||
fs::create_dir_all(&disc_dir)?;
|
||||
write_file(&header.as_bytes()[..0x100], &disc_dir.join("header.bin"), progress)?;
|
||||
if let Some(region) = disc.region() {
|
||||
write_file(region, &disc_dir.join("region.bin"), progress)?;
|
||||
}
|
||||
if let Some(ticket) = meta.raw_ticket.as_deref() {
|
||||
write_file(ticket, &out_dir.join("ticket.bin"), progress)?;
|
||||
}
|
||||
if let Some(tmd) = meta.raw_tmd.as_deref() {
|
||||
write_file(tmd, &out_dir.join("tmd.bin"), progress)?;
|
||||
}
|
||||
if let Some(cert_chain) = meta.raw_cert_chain.as_deref() {
|
||||
write_file(cert_chain, &out_dir.join("cert.bin"), progress)?;
|
||||
}
|
||||
if let Some(h3_table) = meta.raw_h3_table.as_deref() {
|
||||
write_file(h3_table, &out_dir.join("h3.bin"), progress)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file(bytes: &[u8], path: &Path, progress: &mut Progress) -> Outcome<()> {
|
||||
fs::write(path, bytes).map_err(|e| format!("Writing {}: {e}", path.display()))?;
|
||||
progress.advance(bytes.len())
|
||||
}
|
||||
|
||||
/// Runs `body`, turning a failure or a panic into a pending Java exception.
|
||||
fn guarded<T>(env: &mut JNIEnv, fallback: T, body: impl FnOnce(&mut JNIEnv) -> Outcome<T>) -> T {
|
||||
match catch_unwind(AssertUnwindSafe(|| body(env))) {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(failure)) => {
|
||||
// A Java exception raised inside a callback is already pending; keep it.
|
||||
if !env.exception_check().unwrap_or(false) {
|
||||
let _ = match failure {
|
||||
Failure::Error(message) => env.throw_new("java/io/IOException", message),
|
||||
Failure::Cancelled => env.throw_new(CANCELLED_EXCEPTION, "Extraction cancelled"),
|
||||
};
|
||||
}
|
||||
fallback
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = env.throw_new("java/io/IOException", "nod-jni panicked; see logcat");
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_header<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
fd: jint,
|
||||
) -> jstring {
|
||||
guarded(&mut env, std::ptr::null_mut(), |env| Ok(env.new_string(header(fd)?)?.into_raw()))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_readFile<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
fd: jint,
|
||||
path: JString<'local>,
|
||||
) -> jbyteArray {
|
||||
guarded(&mut env, std::ptr::null_mut(), |env| {
|
||||
let path: String = env.get_string(&path)?.into();
|
||||
let bytes = read_file(fd, &path)?;
|
||||
Ok(env.byte_array_from_slice(&bytes)?.into_raw())
|
||||
})
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_extract<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
fd: jint,
|
||||
out_dir: JString<'local>,
|
||||
listener: JObject<'local>,
|
||||
) {
|
||||
guarded(&mut env, (), |env| {
|
||||
let out_dir: String = env.get_string(&out_dir)?.into();
|
||||
let mut report = |done: u64, total: u64| -> Outcome<bool> {
|
||||
let result = env.call_method(
|
||||
&listener,
|
||||
"update",
|
||||
"(JJ)Z",
|
||||
&[JValue::Long(done as i64), JValue::Long(total as i64)],
|
||||
);
|
||||
Ok(result?.z()?)
|
||||
};
|
||||
extract(fd, Path::new(&out_dir), &mut report)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user