mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 00:00:17 +02:00
Added Support for both Base and Retro Rewind in the launher
This commit is contained in:
1 parent
58c0890983
commit
321db74a0f
33 files changed
+733
-377
No files matched your search
+5
-1
@@ -25,7 +25,11 @@ dotnet test translator/Translator.sln -c Release
|
||||
|
||||
`--version` still prints a plain version. `--info-json` additionally reports `productId` as
|
||||
`wiicompiled-openxr-vr`, `version`, `openxrD3D12: true`, and `questBuild: true` when the setup
|
||||
supports `--build-quest` (see `docs/quest-port.md`). VR refuses installation, repair,
|
||||
supports `--build-quest` (see `docs/quest-port.md`). From 0.2.39 every release declares it, and
|
||||
`Verify-Release.ps1` refuses one whose payload lacks the `BuildWorkspace/android` scripts that
|
||||
build runs, so a release that claims Quest support can always deliver it. WheelWizard offers
|
||||
Build for Quest only once the installed setup reports `questBuild`, and asks the player to update
|
||||
otherwise. VR refuses installation, repair,
|
||||
launch, or uninstall of a normal or unidentified installation. Nonportable VR installations use
|
||||
`WiiCompiledOpenXRVR` application data and a distinct uninstall registration and shortcut.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.38</Version>
|
||||
<Version>0.2.39</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -9,6 +9,9 @@ if ($SetupPath) {
|
||||
$info = (& $SetupPath --info-json | Out-String) | ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0 -or $info.productId -cne 'wiicompiled-openxr-vr' -or
|
||||
$info.version -cne $version -or $info.openxrD3D12 -ne $true) { throw 'Setup VR identity mismatch.' }
|
||||
# WheelWizard offers Build for Quest only to a setup that declares it, so a release that says
|
||||
# so must also carry what the build runs.
|
||||
if ($info.questBuild -ne $true) { throw 'Setup does not declare Quest build support.' }
|
||||
# Inspect the appended payload as well: a correctly versioned host can still wrap stale tools.
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
$stream = [IO.File]::OpenRead((Resolve-Path -LiteralPath $SetupPath))
|
||||
@@ -43,6 +46,12 @@ if ($SetupPath) {
|
||||
if ($manifest.ProductId -cne $info.productId -or $manifest.ProductVersion -cne $version) {
|
||||
throw 'Payload identity/version does not match the executable.'
|
||||
}
|
||||
# The scripts --build-quest runs live in the installed workspace, not the toolkit.
|
||||
foreach ($script in 'QuestGameKit.psm1', 'Build-QuestGame.ps1') {
|
||||
if (-not $zip.GetEntry("BuildWorkspace/android/$script")) {
|
||||
throw "The payload has no BuildWorkspace/android/$script, so --build-quest cannot run."
|
||||
}
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
} finally { $payload.Dispose(); $stream.Dispose() }
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ internal sealed class CommandLine
|
||||
public string? QuestApkPath { get; private set; }
|
||||
public string? OutputPath { get; private set; }
|
||||
public bool IncludeGameFiles { get; private set; }
|
||||
/// <summary>Which game the Quest app is to build: "base" (the default) or "retro_rewind".</summary>
|
||||
public string QuestProduct { get; private set; } = "base";
|
||||
public bool IncludeModContent { get; private set; }
|
||||
|
||||
public static bool WantsProgressJson(string[] args) =>
|
||||
args.Any(argument => argument.Equals("--progress-json", StringComparison.OrdinalIgnoreCase));
|
||||
@@ -97,6 +100,8 @@ internal sealed class CommandLine
|
||||
case "--quest-apk": result.QuestApkPath = RequireValue(args, ref i); break;
|
||||
case "--output": result.OutputPath = RequireValue(args, ref i); break;
|
||||
case "--include-game-files": result.IncludeGameFiles = true; break;
|
||||
case "--quest-product": result.QuestProduct = RequireValue(args, ref i); break;
|
||||
case "--include-mod-content": result.IncludeModContent = true; break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown CLI option: {args[i]}");
|
||||
}
|
||||
@@ -149,7 +154,7 @@ internal sealed class CommandLine
|
||||
[AppMode.BuildQuest] = new ModeRules
|
||||
{
|
||||
Flag = "--build-quest", AcceptsProgressJson = true, RequiresInstallDirectory = true,
|
||||
AcceptsQuestBuild = true
|
||||
AcceptsQuestBuild = true, AcceptsRetroDirectory = true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,6 +175,13 @@ internal sealed class CommandLine
|
||||
Reject(QuestApkPath is not null && !rules.AcceptsQuestBuild, "--quest-apk");
|
||||
Reject(OutputPath is not null && !rules.AcceptsQuestBuild, "--output");
|
||||
Reject(IncludeGameFiles && !rules.AcceptsQuestBuild, "--include-game-files");
|
||||
Reject(IncludeModContent && !rules.AcceptsQuestBuild, "--include-mod-content");
|
||||
if (rules.AcceptsQuestBuild && QuestProduct is not ("base" or "retro_rewind"))
|
||||
throw new ArgumentException("--quest-product must be base or retro_rewind.");
|
||||
if (IncludeModContent && QuestProduct != "retro_rewind")
|
||||
throw new ArgumentException("--include-mod-content needs --quest-product retro_rewind.");
|
||||
if (IncludeModContent && string.IsNullOrWhiteSpace(RetroDirectoryPath))
|
||||
throw new ArgumentException("--include-mod-content needs --retro-dir.");
|
||||
if (rules.AcceptsQuestBuild && (string.IsNullOrWhiteSpace(QuestApkPath) || string.IsNullOrWhiteSpace(OutputPath)))
|
||||
throw new ArgumentException($"--quest-apk and --output are required with {rules.Flag}.");
|
||||
Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode,
|
||||
|
||||
@@ -17,7 +17,8 @@ internal static class ConsoleCommands
|
||||
Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " +
|
||||
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
|
||||
Console.Out.WriteLine(" --build-quest --install-dir <dir> --quest-apk <apk> --output <file.wcgame> " +
|
||||
"[--include-game-files] [--progress-json]");
|
||||
"[--quest-product base|retro_rewind] [--include-game-files] " +
|
||||
"[--retro-dir <folder> --include-mod-content] [--progress-json]");
|
||||
Console.Out.WriteLine(" --launch-retro | --launch-base");
|
||||
Console.Out.WriteLine(" --uninstall --install-dir <dir>");
|
||||
Console.Out.WriteLine(" --version");
|
||||
@@ -293,7 +294,8 @@ internal static class ConsoleCommands
|
||||
using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
|
||||
var result = await new QuestBuildService(reporter).BuildAsync(installation,
|
||||
Path.GetFullPath(command.QuestApkPath!), Path.GetFullPath(command.OutputPath!),
|
||||
command.IncludeGameFiles, cancellationToken);
|
||||
command.IncludeGameFiles, cancellationToken, command.QuestProduct,
|
||||
command.IncludeModContent ? Path.GetFullPath(command.RetroDirectoryPath!) : null);
|
||||
if (ndjson is null)
|
||||
Console.Out.WriteLine($"Quest game package: {result.PackagePath} ({result.SizeBytes / 1_000_000} MB)");
|
||||
ndjson?.QuestPackage(result);
|
||||
|
||||
@@ -49,7 +49,8 @@ internal sealed class QuestBuildService
|
||||
internal sealed record Toolchain(string ClangCxx, string ClangC, string Sysroot);
|
||||
|
||||
public async Task<Result> BuildAsync(Installation installation, string apkPath, string outputPath,
|
||||
bool includeGameFiles, CancellationToken cancellationToken)
|
||||
bool includeGameFiles, CancellationToken cancellationToken, string product = "base",
|
||||
string? modContentDirectory = null)
|
||||
{
|
||||
_reporter.Progress(InstallStages.Validate, "Checking the installation and the Quest app...", 1);
|
||||
var workspace = installation.WorkspaceDirectory;
|
||||
@@ -73,14 +74,24 @@ internal sealed class QuestBuildService
|
||||
var kitDirectory = Path.Combine(questRoot, "kit");
|
||||
_reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2);
|
||||
var kit = ExtractKit(apkPath, kitDirectory, cancellationToken);
|
||||
_reporter.Diagnostic($"Quest game kit {kit.Fingerprint} ({kit.Product})");
|
||||
// A Retro Rewind app needs this installation's Retro Rewind translation, which only exists
|
||||
// once the mod has been built here at least once.
|
||||
if (kit.Product == "retro_rewind" && !HasRetroRewindShards(generated))
|
||||
_reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}]");
|
||||
if (!kit.Products.Contains(product))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This Quest app is the Retro Rewind one, and this installation has no Retro Rewind " +
|
||||
"translation. Install or repair Retro Rewind, then build for Quest again.");
|
||||
$"The Quest app cannot play {product}: its game kit carries {string.Join(", ", kit.Products)}.");
|
||||
}
|
||||
// Retro Rewind needs this installation's own Retro Rewind translation, which only exists
|
||||
// once the mod has been built here at least once.
|
||||
if (product == "retro_rewind" && !HasRetroRewindShards(generated))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This installation has no Retro Rewind translation. Install or repair Retro Rewind, " +
|
||||
"then build for Quest again.");
|
||||
}
|
||||
if (modContentDirectory is not null && !File.Exists(Path.Combine(modContentDirectory, "Binaries", "Code.pul")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Retro Rewind content to put in the game file is missing its Binaries/Code.pul.");
|
||||
}
|
||||
|
||||
var toolchain = await EnsureToolchainAsync(questRoot, cancellationToken);
|
||||
@@ -94,13 +105,18 @@ internal sealed class QuestBuildService
|
||||
"-Generated", generated, "-Kit", kitDirectory, "-Manifest", manifest,
|
||||
"-BuildDir", Path.Combine(questRoot, "build"), "-Output", temporaryOutput,
|
||||
"-ClangCxx", toolchain.ClangCxx, "-ClangC", toolchain.ClangC, "-Sysroot", toolchain.Sysroot,
|
||||
"-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}"
|
||||
"-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}", "-Product", product
|
||||
};
|
||||
if (includeGameFiles)
|
||||
{
|
||||
arguments.Add("-Data");
|
||||
arguments.Add(installation.GameDataDirectory);
|
||||
}
|
||||
if (modContentDirectory is not null)
|
||||
{
|
||||
arguments.Add("-Mod");
|
||||
arguments.Add(modContentDirectory);
|
||||
}
|
||||
|
||||
_reporter.Progress(InstallStages.QuestBuild, "Preparing the Quest build...", 40);
|
||||
var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
|
||||
@@ -168,8 +184,8 @@ internal sealed class QuestBuildService
|
||||
File.Exists(Path.Combine(directory, "sys", "fst.bin")) &&
|
||||
File.Exists(Path.Combine(directory, "sys", "main.dol"));
|
||||
|
||||
/// <summary>What the extracted kit says it builds: its fingerprint and product ("base" or "retro_rewind").</summary>
|
||||
internal sealed record Kit(string Fingerprint, string Product);
|
||||
/// <summary>What the extracted kit says it builds: its fingerprint and the products it carries.</summary>
|
||||
internal sealed record Kit(string Fingerprint, IReadOnlyCollection<string> Products);
|
||||
|
||||
/// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and reads what it is for.</summary>
|
||||
internal static Kit ExtractKit(string apkPath, string destination, CancellationToken cancellationToken)
|
||||
@@ -203,11 +219,11 @@ internal sealed class QuestBuildService
|
||||
{
|
||||
throw new InvalidDataException("The Quest app's game kit has no fingerprint.");
|
||||
}
|
||||
var product = document.RootElement.TryGetProperty("product", out var value) &&
|
||||
value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()!
|
||||
: "base";
|
||||
return new Kit(fingerprint.GetString()!, product);
|
||||
var products = document.RootElement.TryGetProperty("products", out var value) &&
|
||||
value.ValueKind == JsonValueKind.Object
|
||||
? value.EnumerateObject().Select(property => property.Name).ToArray()
|
||||
: throw new InvalidDataException("The Quest app's game kit lists no games it can build.");
|
||||
return new Kit(fingerprint.GetString()!, products);
|
||||
}
|
||||
|
||||
private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken)
|
||||
|
||||
@@ -302,9 +302,18 @@ internal static class SelfTests
|
||||
"--output", "D:\\MarioKartWii.wcgame", "--include-game-files", "--progress-json"
|
||||
]);
|
||||
if (build.Mode != AppMode.BuildQuest || build.QuestApkPath != "quest.apk" ||
|
||||
build.OutputPath != "D:\\MarioKartWii.wcgame" || !build.IncludeGameFiles || !build.ProgressJson)
|
||||
build.OutputPath != "D:\\MarioKartWii.wcgame" || !build.IncludeGameFiles || !build.ProgressJson ||
|
||||
build.QuestProduct != "base" || build.IncludeModContent)
|
||||
throw new Exception("The Quest build contract did not parse.");
|
||||
|
||||
var retro = CommandLine.Parse([
|
||||
"--build-quest", "--install-dir", "C:\\Games\\MKW", "--quest-apk", "quest.apk",
|
||||
"--output", "D:\\RetroRewind.wcgame", "--quest-product", "retro_rewind",
|
||||
"--retro-dir", "D:\\RetroRewind6", "--include-mod-content"
|
||||
]);
|
||||
if (retro.QuestProduct != "retro_rewind" || !retro.IncludeModContent || retro.RetroDirectoryPath != "D:\\RetroRewind6")
|
||||
throw new Exception("The Retro Rewind Quest build contract did not parse.");
|
||||
|
||||
void Rejects(string description, params string[] args)
|
||||
{
|
||||
try
|
||||
@@ -319,6 +328,12 @@ internal static class SelfTests
|
||||
Rejects("A Quest build without an installation", "--build-quest", "--quest-apk", "a.apk", "--output", "o");
|
||||
Rejects("--quest-apk outside a Quest build", "--launch-base", "--quest-apk", "a.apk");
|
||||
Rejects("--include-game-files outside a Quest build", "--check-products", "--include-game-files");
|
||||
Rejects("An unknown Quest product", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk",
|
||||
"--output", "o.wcgame", "--quest-product", "mario-kart-8");
|
||||
Rejects("The mod content of a base game", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk",
|
||||
"--output", "o.wcgame", "--retro-dir", "r", "--include-mod-content");
|
||||
Rejects("Mod content without its source", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk",
|
||||
"--output", "o.wcgame", "--quest-product", "retro_rewind", "--include-mod-content");
|
||||
}
|
||||
|
||||
private static void TestQuestToolchainSelection()
|
||||
@@ -369,15 +384,16 @@ internal static class SelfTests
|
||||
using var writer = new StreamWriter(zip.CreateEntry(name).Open());
|
||||
writer.Write(text);
|
||||
}
|
||||
Add("assets/game_kit/kit.json", "{\"schema\":2,\"product\":\"retro_rewind\",\"fingerprint\":\"abc123\"}");
|
||||
Add("assets/game_kit/kit.json", "{\"schema\":3,\"fingerprint\":\"abc123\",\"products\":{\"base\":{},\"retro_rewind\":{}}}");
|
||||
Add("assets/game_kit/include/memory.h", "#pragma once");
|
||||
Add("assets/runtime_resources/dsp_coef.bin", "not the kit");
|
||||
Add("lib/arm64-v8a/libSDL3.so", "not the kit");
|
||||
}
|
||||
var kit = Path.Combine(temp, "kit");
|
||||
var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None);
|
||||
if (extracted.Fingerprint != "abc123" || extracted.Product != "retro_rewind")
|
||||
throw new Exception("The kit fingerprint or product was not read.");
|
||||
if (extracted.Fingerprint != "abc123" || !extracted.Products.Contains("base") ||
|
||||
!extracted.Products.Contains("retro_rewind"))
|
||||
throw new Exception("The kit fingerprint or its games were not read.");
|
||||
if (!File.Exists(Path.Combine(kit, "include", "memory.h")) ||
|
||||
Directory.GetFiles(kit, "*", SearchOption.AllDirectories).Length != 2)
|
||||
throw new Exception("The kit extraction took the wrong files.");
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
64fdd492f6ce71fd223b49f493e0809d9a7f9ed45ebaa3605f626eb77282dbe4 WiiCompiled-Setup.exe
|
||||
b319b86d46bea6ebfa52965c65706976d6004abc01914ff82a2b73c71de84def WheelWizardVRWindows.exe
|
||||
@@ -1 +0,0 @@
|
||||
946bb530c9deea52f397689d637a4e0c10070d9c1980d7d02c4acc78f8fb7e5d WheelWizardVRWindows.exe
|
||||
@@ -1 +0,0 @@
|
||||
46ab8a1d1c6d8af551cdd45808397a0eb4fbbc96bb97e25d7a5a70d6ccde01bf WheelWizardVRWindows.exe
|
||||
+10
-13
@@ -1,8 +1,11 @@
|
||||
# Builds the standalone Meta Quest APK on a Windows host.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>] [-Flavor base|retroRewind]
|
||||
# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>]
|
||||
# [-Configuration debug|release] [-Install]
|
||||
#
|
||||
# One app offers both games: the APK carries a kit for the base game, and for Retro Rewind too
|
||||
# when -Generated holds a translation that includes the mod.
|
||||
#
|
||||
# -Generated names the translator output for the disc you own: the directory
|
||||
# holding data_sections_init.cpp, RuntimeConfig.h and build_shards/shards.cmake.
|
||||
# It defaults to the installer's build workspace next to this checkout. The
|
||||
@@ -17,7 +20,6 @@ param(
|
||||
[string]$Generated = '',
|
||||
[string]$Dependencies = '',
|
||||
[string]$CMakeDir = '',
|
||||
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base',
|
||||
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
|
||||
[switch]$Install
|
||||
)
|
||||
@@ -71,7 +73,7 @@ if (-not $Dependencies) {
|
||||
}
|
||||
|
||||
$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration)
|
||||
$task = "app:assemble$((Get-Culture).TextInfo.ToTitleCase($Flavor))$variant"
|
||||
$task = "app:assemble$variant"
|
||||
$gradleArgs = @(
|
||||
'--project-dir', $root,
|
||||
"-PmkwGeneratedDir=$Generated"
|
||||
@@ -82,13 +84,13 @@ Write-Host "gradlew $($gradleArgs -join ' ')"
|
||||
& (Join-Path $root 'gradlew.bat') @gradleArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" }
|
||||
|
||||
$apkDir = Join-Path $root "app\build\outputs\apk\$Flavor\$Configuration"
|
||||
$apkDir = Join-Path $root "app\build\outputs\apk\$Configuration"
|
||||
$apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1
|
||||
if (-not $apk) { throw "No APK under $apkDir" }
|
||||
Write-Host "APK: $($apk.FullName)"
|
||||
|
||||
# Every APK must be distributable: no translated game inside, only the game kit. The toolchain
|
||||
# that builds one on the headset travels in the base APK only.
|
||||
# The APK must be distributable: no translated game inside, only the game kit and the toolchain
|
||||
# that builds one on the headset.
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
|
||||
try {
|
||||
@@ -98,16 +100,11 @@ try {
|
||||
} 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 ($Flavor -eq 'base' -and -not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' }
|
||||
if ($Flavor -ne 'base' -and $hasToolchain) { throw 'Only the base APK carries the on-headset build toolchain' }
|
||||
if (-not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' }
|
||||
|
||||
if ($Install) {
|
||||
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
|
||||
& $adb install -r $apk.FullName
|
||||
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
|
||||
if ($Flavor -eq 'base') {
|
||||
Write-Host 'Installed. Build the game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
|
||||
} else {
|
||||
Write-Host "Installed. Build its game on this PC with android/Build-QuestGame.ps1 -Flavor $Flavor -Install."
|
||||
}
|
||||
Write-Host 'Installed. Build a game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
|
||||
}
|
||||
+14
-15
@@ -13,9 +13,10 @@
|
||||
# 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 of -Flavor (base or
|
||||
# retroRewind, whose translation must include the mod) exported. -Data adds the
|
||||
# extracted disc (the directory holding sys/ and files/) so the headset needs nothing else.
|
||||
# kit the Quest app carries; it defaults to the one the last APK build exported. -Product picks the
|
||||
# game: base, or retro_rewind (whose translation must include the mod). -Data adds the extracted
|
||||
# disc (the directory holding sys/ and files/) and -Mod the RetroRewind6 pack, 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()]
|
||||
@@ -31,7 +32,8 @@ param(
|
||||
[string]$Sysroot = '',
|
||||
[string]$Ninja = '',
|
||||
[string]$BuiltBy = 'android/Build-QuestGame.ps1',
|
||||
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base',
|
||||
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base',
|
||||
[string]$Mod = '',
|
||||
[int]$TranslatedJobs = 0,
|
||||
[switch]$Install
|
||||
)
|
||||
@@ -42,17 +44,15 @@ $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\$Flavor" }
|
||||
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Product" }
|
||||
if (-not $Kit) {
|
||||
# The kit this flavour's last APK build packaged (its export<Flavor>*QuestGameKit task), so the
|
||||
# game matches the app it will be imported into.
|
||||
$exportTask = 'export' + $Flavor.Substring(0, 1).ToUpper() + $Flavor.Substring(1)
|
||||
# The kit the last APK build packaged, 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' -and $_.FullName -like "*$exportTask*" } |
|
||||
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 $Flavor game kit; run Build-Quest.ps1 -Flavor $Flavor first, or pass -Kit"
|
||||
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" }
|
||||
|
||||
@@ -85,19 +85,18 @@ if (-not $gameId -or -not $dolSha -or -not $relSha) { throw "Cannot read the dis
|
||||
|
||||
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$library = Invoke-QuestGameBuild -KitDir $Kit -GeneratedDir $Generated -BuildDir $BuildDir `
|
||||
-ClangCxx $ClangCxx -ClangC $ClangC -Sysroot $Sysroot -Ninja $Ninja -TranslatedJobs $TranslatedJobs
|
||||
-ClangCxx $ClangCxx -ClangC $ClangC -Sysroot $Sysroot -Ninja $Ninja -TranslatedJobs $TranslatedJobs -Product $Product
|
||||
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
|
||||
$game = New-QuestGamePackage -Library $library -DataDir $Data -ModDir $Mod -KitDir $Kit -GameId $gameId -DolSha256 $dolSha -RelSha256 $relSha `
|
||||
-BuiltBy $BuiltBy -OutputPath $Output -Product $Product
|
||||
Write-Host "Game package: $Output (kit $($game.kitFingerprint))"
|
||||
|
||||
if ($Install) {
|
||||
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
|
||||
$package = if ($Flavor -eq 'retroRewind') { 'org.wiicompiled.quest.retrorewind' } else { 'org.wiicompiled.quest' }
|
||||
$import = "/sdcard/Android/data/$package/files/WiiCompiledOpenXRVR/Import"
|
||||
$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()
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# 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, once per app flavour.
|
||||
# the APK's game_kit assets. One kit carries a recipe per product the tree built a probe for:
|
||||
# the base game always, and Retro Rewind when the translation includes the mod.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir <dir> -OutputDir <dir> -LlvmStrip <llvm-strip> [-Product base|retro_rewind]
|
||||
# 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,
|
||||
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
|
||||
[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 -Product $Product
|
||||
Write-Host "Quest game kit ($Product) $fingerprint -> $OutputDir"
|
||||
$kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip
|
||||
Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', ')] -> $OutputDir"
|
||||
+118
-95
@@ -24,7 +24,7 @@
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$script:KitSchema = 2
|
||||
$script:KitSchema = 3
|
||||
$script:GameSchema = 1
|
||||
|
||||
# What each app flavour's kit is made of. Slots name the game's objects in the probe's link line,
|
||||
@@ -268,34 +268,15 @@ function Export-QuestGameKit {
|
||||
[Parameter(Mandatory)] [string]$CMakeBinaryDir,
|
||||
[Parameter(Mandatory)] [string]$OutputDir,
|
||||
[Parameter(Mandatory)] [string]$RepoRoot,
|
||||
[Parameter(Mandatory)] [string]$LlvmStrip,
|
||||
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
|
||||
[Parameter(Mandatory)] [string]$LlvmStrip
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$binary = ConvertTo-ForwardPath $CMakeBinaryDir
|
||||
$runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include')
|
||||
$kitProfile = $script:KitProfiles[$Product]
|
||||
|
||||
# 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*$([regex]::Escape($kitProfile.Probe))\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)")
|
||||
if (-not $edge.Success) { throw "No $($kitProfile.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"
|
||||
$rules = if (Test-Path "$binary/CMakeFiles/rules.ninja") { [IO.File]::ReadAllText("$binary/CMakeFiles/rules.ninja") } else { '' }
|
||||
|
||||
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" }
|
||||
@@ -307,20 +288,15 @@ function Export-QuestGameKit {
|
||||
param($Entry)
|
||||
ConvertTo-KitCompileFlags -Arguments (Split-CommandLine $Entry.command) -RuntimeInclude $runtimeInclude -Workspace $workspace
|
||||
}
|
||||
$compile = [ordered]@{
|
||||
translated = & $flagsFor $translatedEntry
|
||||
product = & $flagsFor (Find-Command $kitProfile.RegistrationPattern)
|
||||
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
|
||||
New-Item -ItemType Directory -Force (Join-Path $OutputDir 'objects'), (Join-Path $OutputDir 'libs') | Out-Null
|
||||
Copy-Item -Recurse (Join-Path $RepoRoot 'runtime/include') (Join-Path $OutputDir 'include')
|
||||
|
||||
# One copy of every object and archive, however many products link it: the two probes differ
|
||||
# only in their own product object.
|
||||
$copied = @{}
|
||||
$script:kitIndex = 0
|
||||
$copy = {
|
||||
param([string]$Source, [string]$Directory)
|
||||
$full = ConvertTo-ForwardPath $(if ([IO.Path]::IsPathRooted($Source)) { $Source } else { "$binary/$Source" })
|
||||
@@ -333,51 +309,91 @@ function Export-QuestGameKit {
|
||||
$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 ($objects[$i] -match $kitProfile.ProductObjectPattern) { $inputs.Add('{game:product}') }
|
||||
if ($i -eq $lastRuntime) {
|
||||
$inputs.Add('{game:runtime}')
|
||||
# Retro Rewind links the mod's own objects after the runtime's, as its CMake target does.
|
||||
if ($kitProfile.Slots -contains 'mod') { $inputs.Add('{game:mod}') }
|
||||
# One recipe per product this CMake tree built a probe for. The base one must be there; Retro
|
||||
# Rewind's exists only once translate-mod and emit-build-shards have run.
|
||||
$products = [ordered]@{}
|
||||
foreach ($product in 'base', 'retro_rewind') {
|
||||
$kitProfile = $script:KitProfiles[$product]
|
||||
$edge = [regex]::Match($ninja, "(?m)^build (\S*$([regex]::Escape($kitProfile.Probe))\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)")
|
||||
if (-not $edge.Success) {
|
||||
if ($product -eq 'base') { throw "No $($kitProfile.Probe) link edge in $binary/build.ninja" }
|
||||
Write-Host "No $($kitProfile.Probe) in this build; the kit carries the base game only."
|
||||
continue
|
||||
}
|
||||
}
|
||||
foreach ($slot in $kitProfile.Slots) {
|
||||
if ($slot -ne 'translated' -and -not $inputs.Contains("{game:$slot}")) {
|
||||
throw "The probe link line lacks the objects the {game:$slot} slot follows"
|
||||
$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] }
|
||||
}
|
||||
}
|
||||
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' }
|
||||
$rule = [regex]::Match($ninja, "(?ms)^rule $([regex]::Escape($edge.Groups[2].Value))\r?\n.*?command = ([^\r\n]*)")
|
||||
if (-not $rule.Success) {
|
||||
$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)" }
|
||||
|
||||
$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])
|
||||
# Link inputs in the probe's order, with the game's slots marked where the product 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 ($objects[$i] -match $kitProfile.ProductObjectPattern) { $inputs.Add('{game:product}') }
|
||||
if ($i -eq $lastRuntime) {
|
||||
$inputs.Add('{game:runtime}')
|
||||
# Retro Rewind links the mod's own objects after the runtime's, as its CMake target does.
|
||||
if ($kitProfile.Slots -contains 'mod') { $inputs.Add('{game:mod}') }
|
||||
}
|
||||
}
|
||||
foreach ($slot in $kitProfile.Slots) {
|
||||
if ($slot -ne 'translated' -and -not $inputs.Contains("{game:$slot}")) {
|
||||
throw "The $product probe link line lacks the objects the {game:$slot} slot follows"
|
||||
}
|
||||
}
|
||||
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 $product 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')
|
||||
|
||||
$products[$product] = [ordered]@{
|
||||
output = 'libmain.so'
|
||||
target = $target
|
||||
compile = [ordered]@{
|
||||
translated = & $flagsFor $translatedEntry
|
||||
product = & $flagsFor (Find-Command $kitProfile.RegistrationPattern)
|
||||
runtime = & $flagsFor (Find-Command '/generated/data_sections_init\.cpp$')
|
||||
asm = & $flagsFor (Find-Command 'data_sections_init_blobs[^/]*\.S$')
|
||||
}
|
||||
sources = $kitProfile.Sources
|
||||
link = [ordered]@{
|
||||
flags = $linkFlags.ToArray()
|
||||
inputs = $inputs.ToArray()
|
||||
lld = Get-KitLldArguments -ClangCxx (Join-Path (Split-Path -Parent $LlvmStrip) 'clang++.exe') -KitDir $OutputDir `
|
||||
-Flags $linkFlags.ToArray() -Inputs $inputs.ToArray() -Slots $kitProfile.Slots
|
||||
}
|
||||
}
|
||||
}
|
||||
$linkFlags.Add('-Wl,-soname,libmain.so')
|
||||
$lld = Get-KitLldArguments -ClangCxx (Join-Path (Split-Path -Parent $LlvmStrip) 'clang++.exe') -KitDir $OutputDir `
|
||||
-Flags $linkFlags.ToArray() -Inputs $inputs.ToArray() -Slots $kitProfile.Slots
|
||||
|
||||
# What the headset's translator needs besides the disc: the game manifest and symbol map, and
|
||||
# the runtime sources it indexes for native registrations, which must be the ones compiled into
|
||||
@@ -389,13 +405,8 @@ function Export-QuestGameKit {
|
||||
|
||||
$recipe = [ordered]@{
|
||||
schema = $script:KitSchema
|
||||
product = $Product
|
||||
output = 'libmain.so'
|
||||
target = $target
|
||||
runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include')
|
||||
compile = $compile
|
||||
sources = $kitProfile.Sources
|
||||
link = [ordered]@{ flags = $linkFlags.ToArray(); inputs = $inputs.ToArray(); lld = $lld }
|
||||
products = $products
|
||||
}
|
||||
$recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress
|
||||
$files = Get-RelativeFiles $OutputDir | Sort-Object FullName | ForEach-Object {
|
||||
@@ -403,7 +414,7 @@ function Export-QuestGameKit {
|
||||
}
|
||||
$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
|
||||
return [pscustomobject]@{ Fingerprint = $recipe.fingerprint; Products = @($products.Keys) }
|
||||
}
|
||||
|
||||
# The generated source lists emit-build-shards wrote into shards.cmake.
|
||||
@@ -425,7 +436,8 @@ function Invoke-QuestGameBuild {
|
||||
[Parameter(Mandatory)] [string]$ClangC,
|
||||
[Parameter(Mandatory)] [string]$Sysroot,
|
||||
[Parameter(Mandatory)] [string]$Ninja,
|
||||
[Parameter(Mandatory)] [int]$TranslatedJobs
|
||||
[Parameter(Mandatory)] [int]$TranslatedJobs,
|
||||
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$kit = ConvertTo-ForwardPath $KitDir
|
||||
@@ -433,6 +445,10 @@ function Invoke-QuestGameBuild {
|
||||
$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)" }
|
||||
if (-not $recipe.products.PSObject.Properties.Name.Contains($Product)) {
|
||||
throw "This Quest app's game kit cannot build $Product (it has: $($recipe.products.PSObject.Properties.Name -join ', '))"
|
||||
}
|
||||
$productRecipe = $recipe.products.$Product
|
||||
# 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'
|
||||
@@ -447,10 +463,10 @@ function Invoke-QuestGameBuild {
|
||||
$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 $_) })
|
||||
$lines = @($productRecipe.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 $_) })
|
||||
$linkLines = @($productRecipe.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 blob assembly for PE/COFF; every one of them is rewritten for
|
||||
@@ -469,9 +485,9 @@ function Invoke-QuestGameBuild {
|
||||
# file.
|
||||
$shards = "$generated/build_shards/shards.cmake"
|
||||
$sources = [ordered]@{}
|
||||
foreach ($slot in $recipe.sources.PSObject.Properties.Name) {
|
||||
foreach ($slot in $productRecipe.sources.PSObject.Properties.Name) {
|
||||
$slotSources = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($entry in $recipe.sources.$slot) {
|
||||
foreach ($entry in $productRecipe.sources.$slot) {
|
||||
if ($entry.StartsWith('@')) {
|
||||
Read-ShardList $shards $entry.Substring(1) | ForEach-Object { $slotSources.Add($_) }
|
||||
} else {
|
||||
@@ -482,7 +498,7 @@ function Invoke-QuestGameBuild {
|
||||
}
|
||||
if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" }
|
||||
foreach ($slot in $sources.Keys) {
|
||||
if ($sources[$slot].Count -eq 0) { throw "The $slot sources of a $($recipe.product) game are missing from $shards" }
|
||||
if ($sources[$slot].Count -eq 0) { throw "The $slot sources of a $Product game are missing from $shards" }
|
||||
}
|
||||
|
||||
$ninjaText = New-Object System.Text.StringBuilder
|
||||
@@ -511,7 +527,7 @@ function Invoke-QuestGameBuild {
|
||||
}
|
||||
}
|
||||
$linkInputs = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($linkInput in $recipe.link.inputs) {
|
||||
foreach ($linkInput in $productRecipe.link.inputs) {
|
||||
$slot = [regex]::Match($linkInput, '^\{game:(\w+)\}$').Groups[1].Value
|
||||
if (-not $slot) { $linkInputs.Add((& $expand $linkInput)); continue }
|
||||
if (-not $objectsOf.ContainsKey($slot)) { throw "The kit recipe links a {game:$slot} slot it names no sources for" }
|
||||
@@ -534,33 +550,38 @@ function Invoke-QuestGameBuild {
|
||||
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.
|
||||
# A .wcgame: the built library and what it was built from, for the headset to import. It can also
|
||||
# carry, written without compression, the extracted disc (-DataDir, as DATA/...) and the Retro
|
||||
# Rewind pack (-ModDir, as MOD/...), so a player who builds on a PC copies one file to the headset
|
||||
# and needs nothing else there.
|
||||
function New-QuestGamePackage {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Library,
|
||||
[string]$DataDir = '',
|
||||
[string]$ModDir = '',
|
||||
[Parameter(Mandatory)] [string]$KitDir,
|
||||
[Parameter(Mandatory)] [string]$GameId,
|
||||
[Parameter(Mandatory)] [string]$DolSha256,
|
||||
[Parameter(Mandatory)] [string]$RelSha256,
|
||||
[Parameter(Mandatory)] [string]$BuiltBy,
|
||||
[Parameter(Mandatory)] [string]$OutputPath
|
||||
[Parameter(Mandatory)] [string]$OutputPath,
|
||||
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$recipe = [IO.File]::ReadAllText((Join-Path $KitDir 'kit.json')) | ConvertFrom-Json
|
||||
if ($ModDir -and $Product -ne 'retro_rewind') { throw 'Only a Retro Rewind game carries the mod pack.' }
|
||||
$game = [ordered]@{
|
||||
schema = $script:GameSchema
|
||||
profile = $recipe.product
|
||||
profile = $Product
|
||||
gameId = $GameId
|
||||
dolSha256 = $DolSha256
|
||||
relSha256 = $RelSha256
|
||||
kitFingerprint = $recipe.fingerprint
|
||||
library = $recipe.output
|
||||
library = $recipe.products.$Product.output
|
||||
librarySha256 = Get-Sha256Hex $Library
|
||||
includesData = [bool]$DataDir
|
||||
includesMod = [bool]$ModDir
|
||||
builtBy = $BuiltBy
|
||||
builtAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
}
|
||||
@@ -572,10 +593,12 @@ function New-QuestGamePackage {
|
||||
$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) {
|
||||
foreach ($file in Get-RelativeFiles $DataDir) {
|
||||
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file.FullName, "DATA/$($file.Relative)", 'NoCompression')
|
||||
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $game.library, 'Optimal')
|
||||
foreach ($tree in @{ Prefix = 'DATA'; Directory = $DataDir }, @{ Prefix = 'MOD'; Directory = $ModDir }) {
|
||||
if (-not $tree.Directory) { continue }
|
||||
foreach ($file in Get-RelativeFiles $tree.Directory) {
|
||||
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||
$zip, $file.FullName, "$($tree.Prefix)/$($file.Relative)", 'NoCompression')
|
||||
}
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
|
||||
+20
-11
@@ -9,17 +9,25 @@ holds the Gradle project, its helper scripts, the game kit tooling
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # base game, debug-signed, installs over adb
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind product
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, debug-signed, installs over adb
|
||||
```
|
||||
|
||||
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, and neither is any translated game code: the base APK carries a game kit,
|
||||
and your `libmain.so` is built from your own disc, either on the headset
|
||||
(**Build on this Quest**, about half an hour) or on a PC and imported
|
||||
(`Build-QuestGame.ps1 -Install`, or **Import from computer** with its `.wcgame`).
|
||||
One app offers both games, with a toggle on Home: Mario Kart Wii and, when your
|
||||
translation includes the mod, Retro Rewind. 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, and neither is any translated game code:
|
||||
the APK carries one game kit holding a recipe per game, and your `libmain.so` is
|
||||
built from your own disc, either on the headset (**Build on this Quest**, about
|
||||
half an hour) or on a PC and imported:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product base -Install
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install
|
||||
```
|
||||
|
||||
`-Mod` puts the Retro Rewind pack in the package, which is how the headset gets
|
||||
it; **Import from computer** takes the same `.wcgame` from the Import folder.
|
||||
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
|
||||
@@ -32,8 +40,9 @@ disc to
|
||||
`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
|
||||
**Home** picks the game with a toggle, starts it in the headset and says what is
|
||||
still missing (the disc files, the game itself, or Retro Rewind's pack), and
|
||||
**Settings** edits `Config.toml` (VR camera, render scale, virtual screen,
|
||||
resolution, controllers, audio). The game itself is `QuestActivity`, in its own
|
||||
resolution, controllers, audio) and lists both games. The game itself is `QuestActivity`, in its own
|
||||
`:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity`
|
||||
still starts it directly.
|
||||
@@ -42,6 +42,13 @@ val discPins: Map<String, String> = run {
|
||||
)
|
||||
}
|
||||
|
||||
// Whether the translation this build is pointed at includes the Retro Rewind mod, which decides
|
||||
// whether the kit can carry that product (emit-build-shards records it in shards.cmake).
|
||||
val hasRetroRewindShards: Boolean = run {
|
||||
val shards = File(mkwGeneratedDir ?: return@run false, "build_shards/shards.cmake")
|
||||
shards.isFile && shards.readText().contains("set(MKW_HAVE_RETRO_REWIND_SHARDS ON)")
|
||||
}
|
||||
|
||||
// android/nod-jni: nod, the disc image library the PC installer runs as nodtool,
|
||||
// cross-compiled with cargo for the launcher's "Select disc image". Needs a Rust
|
||||
// toolchain with the aarch64-linux-android target (see docs/quest-port.md).
|
||||
@@ -100,12 +107,23 @@ android {
|
||||
for ((name, value) in discPins) {
|
||||
buildConfigField("String", name, "\"$value\"")
|
||||
}
|
||||
// The APK carries game kits, not games: the player's libmain.so is built from their own
|
||||
// disc and loaded from private storage (see android/QuestGameKit.psm1). One app offers
|
||||
// both products, like the PC launcher, and the launcher's toggle picks which one to play.
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
buildConfigField("boolean", "ON_DEVICE_BUILD", "true")
|
||||
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
// One probe per product the translation can build. Retro Rewind's exists only
|
||||
// once translate-mod and emit-build-shards have run over the mod.
|
||||
targets += "mkw_quest_kit_probe"
|
||||
if (hasRetroRewindShards) {
|
||||
targets += "mkw_quest_kit_probe_retro"
|
||||
}
|
||||
arguments += listOf(
|
||||
"-DANDROID_STL=c++_shared",
|
||||
"-DANDROID_PLATFORM=android-29",
|
||||
@@ -121,33 +139,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "profile"
|
||||
productFlavors {
|
||||
create("base") {
|
||||
dimension = "profile"
|
||||
buildConfigField("String", "MAIN_LIBRARY", "\"main\"")
|
||||
buildConfigField("String", "PROFILE", "\"base\"")
|
||||
// The APK carries the game kit, not the game: the player's libmain.so is built from
|
||||
// their own disc and loaded from private storage (see android/QuestGameKit.psm1).
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
// And it carries the toolchain that builds it on the headset (Prepare-QuestToolchain.ps1).
|
||||
buildConfigField("boolean", "ON_DEVICE_BUILD", "true")
|
||||
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe" } }
|
||||
}
|
||||
create("retroRewind") {
|
||||
dimension = "profile"
|
||||
applicationIdSuffix = ".retrorewind"
|
||||
versionNameSuffix = "-retro-rewind"
|
||||
buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"")
|
||||
buildConfigField("String", "PROFILE", "\"retro_rewind\"")
|
||||
buildConfigField("boolean", "EXTERNAL_GAME", "true")
|
||||
// Retro Rewind is built on a PC and imported: building it on the headset would also
|
||||
// need the mod's Code.pul and its content there, which the launcher cannot fetch yet.
|
||||
buildConfigField("boolean", "ON_DEVICE_BUILD", "false")
|
||||
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe_retro" } }
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
// aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1;
|
||||
@@ -204,10 +195,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
|
||||
@get:Internal
|
||||
abstract val llvmStrip: RegularFileProperty
|
||||
|
||||
/** "base" or "retro_rewind": which product's kit this flavour carries. */
|
||||
@get:Internal
|
||||
abstract val product: Property<String>
|
||||
|
||||
@get:Inject
|
||||
abstract val execOperations: ExecOperations
|
||||
|
||||
@@ -219,11 +206,10 @@ abstract class ExportQuestGameKit : DefaultTask() {
|
||||
@TaskAction
|
||||
fun export() {
|
||||
val app = appDir.get().asFile
|
||||
val library = if (product.get() == "base") "libmkw_quest_kit_probe.so" else "libmkw_quest_kit_probe_retro.so"
|
||||
val probe = File(app, "build/intermediates/cxx").walkTopDown()
|
||||
.filter { it.name == library && it.parentFile.name == "arm64-v8a" }
|
||||
.filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
|
||||
.maxByOrNull { it.lastModified() }
|
||||
?: throw GradleException("No $library; the native build did not produce the game kit probe")
|
||||
?: throw GradleException("No libmkw_quest_kit_probe.so; the native build did not produce the game kit probe")
|
||||
val configuration = probe.parentFile.parentFile.parentFile // <BuildType>/<hash>
|
||||
val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a")
|
||||
if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir")
|
||||
@@ -232,7 +218,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
|
||||
commandLine(
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
|
||||
"-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path,
|
||||
"-Product", product.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -275,33 +260,27 @@ androidComponents {
|
||||
onVariants { variant ->
|
||||
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
|
||||
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
||||
// Both flavours ship a kit instead of the game. AGP packages every library in the CMake
|
||||
// output directory, so a game library left over from an earlier build would otherwise
|
||||
// travel in the APK.
|
||||
// The APK ships kits instead of games. AGP packages every library in the CMake output
|
||||
// directory, so a game library left over from an earlier build would otherwise travel in it.
|
||||
variant.packaging.jniLibs.excludes.add("**/libmain*.so")
|
||||
val export = tasks.register<ExportQuestGameKit>("export${capitalized}QuestGameKit") {
|
||||
dependsOn("merge${capitalized}NativeLibs")
|
||||
appDir.set(layout.projectDirectory)
|
||||
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
|
||||
product.set(if (variant.flavorName == "retroRewind") "retro_rewind" else "base")
|
||||
llvmStrip.set(sdkComponents.ndkDirectory.map {
|
||||
it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")
|
||||
})
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(export, ExportQuestGameKit::outputDir)
|
||||
|
||||
// Only the base flavour builds the game on the headset, so only it carries the toolchain:
|
||||
// Retro Rewind also needs the mod's own content and translation, which is a PC build for now.
|
||||
if (variant.flavorName != "retroRewind") {
|
||||
val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") {
|
||||
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
|
||||
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
|
||||
sources.from(fileTree(File(mkwRepoRoot, "translator/src")) { exclude("**/bin/**", "**/obj/**") })
|
||||
sources.from(fileTree(File(mkwRepoRoot, "Launcher/WiiCompiled.Setup.Common")) { exclude("**/bin/**", "**/obj/**") })
|
||||
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
|
||||
val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") {
|
||||
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
|
||||
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
|
||||
sources.from(fileTree(File(mkwRepoRoot, "translator/src")) { exclude("**/bin/**", "**/obj/**") })
|
||||
sources.from(fileTree(File(mkwRepoRoot, "Launcher/WiiCompiled.Setup.Common")) { exclude("**/bin/**", "**/obj/**") })
|
||||
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The player's compiled game: libmain.so built from their own disc against the game kit this
|
||||
* APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with.
|
||||
* A player's compiled game: libmain.so built from their own disc against the game kit this APK
|
||||
* carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with. One
|
||||
* per [GameProfile], so the unmodded game and Retro Rewind can both be installed.
|
||||
*
|
||||
* It lives in internal private storage, the one place Android lets an app load native code it
|
||||
* did not install (execution from shared or external storage is blocked). A library built for
|
||||
@@ -23,13 +24,16 @@ object GameLibrary {
|
||||
@Volatile
|
||||
private var cachedKitFingerprint: String? = null
|
||||
|
||||
fun directory(context: Context): File = File(context.filesDir, "game/${BuildConfig.PROFILE}")
|
||||
fun directory(context: Context, profile: GameProfile = GameProfile.selected(context)): File =
|
||||
File(context.filesDir, "game/${profile.id}")
|
||||
|
||||
fun library(context: Context): File = File(directory(context), LIBRARY_NAME)
|
||||
fun library(context: Context, profile: GameProfile = GameProfile.selected(context)): File =
|
||||
File(directory(context, profile), LIBRARY_NAME)
|
||||
|
||||
fun manifest(context: Context): JSONObject? = File(directory(context), MANIFEST_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
fun manifest(context: Context, profile: GameProfile = GameProfile.selected(context)): JSONObject? =
|
||||
File(directory(context, profile), MANIFEST_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
|
||||
/** The fingerprint of the kit in this APK's assets, which every game must be built against. */
|
||||
fun kitFingerprint(context: Context): String? {
|
||||
@@ -41,12 +45,9 @@ object GameLibrary {
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
fun status(context: Context): Status {
|
||||
if (!BuildConfig.EXTERNAL_GAME) {
|
||||
return Status.Ready
|
||||
}
|
||||
val manifest = manifest(context)
|
||||
if (manifest == null || !library(context).isFile) {
|
||||
fun status(context: Context, profile: GameProfile = GameProfile.selected(context)): Status {
|
||||
val manifest = manifest(context, profile)
|
||||
if (manifest == null || !library(context, profile).isFile) {
|
||||
return Status.Missing
|
||||
}
|
||||
return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.wiicompiled.quest
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The games this app can play, as the PC launcher offers them: the unmodded game and Retro Rewind.
|
||||
*
|
||||
* One APK carries a kit for each (`assets/game_kit`, see android/QuestGameKit.psm1) and the player
|
||||
* keeps a built game per profile in private storage, so switching between them is a toggle rather
|
||||
* than a second app. The choice is a file, not a preference, because the launcher and the game run
|
||||
* in different processes and the game reads it while starting.
|
||||
*/
|
||||
enum class GameProfile(val id: String, val title: Int, val modPack: Boolean) {
|
||||
Base("base", R.string.home_title_base, modPack = false),
|
||||
RetroRewind("retro_rewind", R.string.home_title_retro_rewind, modPack = true);
|
||||
|
||||
companion object {
|
||||
private const val SELECTION_FILE = "selected-game"
|
||||
|
||||
fun of(id: String?): GameProfile? = entries.firstOrNull { it.id == id }
|
||||
|
||||
/** The profiles this APK's kit can build and load, in launcher order. */
|
||||
fun available(context: Context): List<GameProfile> {
|
||||
val products = runCatching {
|
||||
context.assets.open("game_kit/kit.json").use { input ->
|
||||
JSONObject(input.reader().readText()).getJSONObject("products").keys().asSequence().toSet()
|
||||
}
|
||||
}.getOrDefault(setOf(Base.id))
|
||||
return entries.filter { it.id in products }.ifEmpty { listOf(Base) }
|
||||
}
|
||||
|
||||
/** The profile the player last chose, or the first available one. */
|
||||
fun selected(context: Context): GameProfile {
|
||||
val available = available(context)
|
||||
val stored = runCatching { File(context.filesDir, SELECTION_FILE).readText().trim() }.getOrNull()
|
||||
return of(stored)?.takeIf { it in available } ?: available.first()
|
||||
}
|
||||
|
||||
fun select(context: Context, profile: GameProfile) {
|
||||
File(context.filesDir, SELECTION_FILE).writeText(profile.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.wiicompiled.quest
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import org.wiicompiled.quest.launcher.TomlConfig
|
||||
|
||||
/**
|
||||
* Where the game's user files live on the headset. The launcher and the game
|
||||
@@ -30,9 +31,12 @@ object GameStorage {
|
||||
|
||||
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY)
|
||||
|
||||
/** Whether this app needs the Retro Rewind pack and has it. Always true for the base game. */
|
||||
fun modContentReady(context: Context): Boolean =
|
||||
BuildConfig.PROFILE != "retro_rewind" || File(modDirectory(context), "Binaries/Code.pul").isFile
|
||||
/** The pack's own Code.pul, which is what a modded game and a mod translation both need. */
|
||||
fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul")
|
||||
|
||||
/** Whether the pack a profile needs is there. Always true for the unmodded game. */
|
||||
fun modContentReady(context: Context, profile: GameProfile): Boolean =
|
||||
!profile.modPack || modCodePul(context).isFile
|
||||
|
||||
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
|
||||
|
||||
@@ -58,7 +62,8 @@ object GameStorage {
|
||||
|
||||
/**
|
||||
* Creates the game directory and a first Config.toml with VR on and the
|
||||
* disc path filled in. An existing file is left alone: the launcher, the
|
||||
* disc path filled in. An existing file keeps every setting it has, and only
|
||||
* gains the Retro Rewind pack path when it predates it: the launcher, the
|
||||
* in-headset settings panel and the player all edit it in place.
|
||||
*/
|
||||
fun prepare(context: Context): File {
|
||||
@@ -75,10 +80,9 @@ object GameStorage {
|
||||
"[paths]",
|
||||
"dvd_root = \"${discDirectory(context).absolutePath}\"",
|
||||
)
|
||||
// The modded product reads its pack from retro_rewind_root, which sits next to DATA here.
|
||||
if (BuildConfig.PROFILE == "retro_rewind") {
|
||||
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
|
||||
}
|
||||
// The modded product reads its pack from retro_rewind_root, which sits next to DATA
|
||||
// here. Harmless for the unmodded game, and one config serves both.
|
||||
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
|
||||
lines += listOf(
|
||||
"",
|
||||
"[video]",
|
||||
@@ -90,6 +94,14 @@ object GameStorage {
|
||||
"render_scale = 1.0",
|
||||
)
|
||||
config.writeText(lines.joinToString("\n", postfix = "\n"))
|
||||
} else {
|
||||
// A config written before this app offered Retro Rewind names no pack, and the modded
|
||||
// game would find none. Only that one line is added; the rest is the player's.
|
||||
val toml = TomlConfig.parse(config.readText())
|
||||
if (toml.string("paths", "retro_rewind_root").isNullOrBlank()) {
|
||||
toml.setString("paths", "retro_rewind_root", modDirectory(context).absolutePath)
|
||||
config.writeText(toml.text())
|
||||
}
|
||||
}
|
||||
return gameRoot
|
||||
}
|
||||
|
||||
@@ -40,27 +40,26 @@ import org.libsdl.app.SDLSurface
|
||||
*/
|
||||
class QuestActivity : SDLActivity() {
|
||||
|
||||
override fun getLibraries(): Array<String> =
|
||||
if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
|
||||
/** The game the launcher's toggle last selected; read once, since a session plays one game. */
|
||||
private val profile: GameProfile by lazy { GameProfile.selected(this) }
|
||||
|
||||
override fun getLibraries(): Array<String> = arrayOf("SDL3")
|
||||
|
||||
/**
|
||||
* The game is not part of the APK: it is the libmain.so the player built from their own disc
|
||||
* ([GameLibrary]), loaded from private storage. Its libSDL3.so, libpng16.so and
|
||||
* libc++_shared.so dependencies resolve against the ones this APK installed.
|
||||
* ([GameLibrary]) for the selected profile, loaded from private storage. Its libSDL3.so,
|
||||
* libpng16.so and libc++_shared.so dependencies resolve against the ones this APK installed.
|
||||
*/
|
||||
override fun loadLibraries() {
|
||||
super.loadLibraries()
|
||||
if (BuildConfig.EXTERNAL_GAME) {
|
||||
// SDLActivity reports a failed load in its own error dialog and never starts the game.
|
||||
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
|
||||
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
|
||||
}
|
||||
System.load(GameLibrary.library(this).absolutePath)
|
||||
// SDLActivity reports a failed load in its own error dialog and never starts the game.
|
||||
if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
|
||||
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
|
||||
}
|
||||
System.load(GameLibrary.library(this, profile).absolutePath)
|
||||
}
|
||||
|
||||
override fun getMainSharedObject(): String =
|
||||
if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject()
|
||||
override fun getMainSharedObject(): String = GameLibrary.library(this, profile).absolutePath
|
||||
|
||||
override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context)
|
||||
|
||||
|
||||
@@ -60,6 +60,14 @@ object BuildRecipe {
|
||||
Regex("""\n\s*entry_points:\s*\n\s*-\s*(0x[0-9A-Fa-f]{8})""").find(manifest)?.groupValues?.get(1)
|
||||
?: throw IllegalArgumentException("recomp.yml names no translation entry point")
|
||||
|
||||
/**
|
||||
* Where recomp.yml's Retro Rewind profile expects the mod, relative to the workspace. A base
|
||||
* translation only accounts for a mod whose Code.pul is there.
|
||||
*/
|
||||
fun modRoot(manifest: String): String =
|
||||
Regex("""\n\s*mod_root:\s*(\S+)""").find(manifest)?.groupValues?.get(1)
|
||||
?: throw IllegalArgumentException("recomp.yml names no Retro Rewind mod_root")
|
||||
|
||||
/**
|
||||
* The blob assembly for ELF, as runtime/cmake/PublicProducts.cmake rewrites a Windows-generated
|
||||
* one. The headset's translator already writes ELF sections, so this normally changes nothing.
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ object GameBuild {
|
||||
}
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val KIT_SCHEMA = 2
|
||||
private const val KIT_SCHEMA = 3
|
||||
private const val TOOLCHAIN_ASSETS = "quest_toolchain"
|
||||
private const val KIT_ASSETS = "game_kit"
|
||||
private const val MARKER = ".complete"
|
||||
@@ -65,8 +66,8 @@ object GameBuild {
|
||||
private const val EXPECTED_TRANSLATION_SECONDS = 600.0
|
||||
private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024
|
||||
|
||||
/** Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
|
||||
/** Builds [profile]'s game. Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, profile: GameProfile, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
|
||||
if (!BuildConfig.ON_DEVICE_BUILD) {
|
||||
return "This app cannot build its game on the headset. Build it on a computer and use Import from computer."
|
||||
}
|
||||
@@ -74,6 +75,10 @@ object GameBuild {
|
||||
if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
|
||||
return "The game files (DATA) are needed to build the game. Select your disc image first."
|
||||
}
|
||||
if (!GameStorage.modContentReady(context, profile)) {
|
||||
return "Retro Rewind needs its pack before it can be built. Import a Retro Rewind game file that " +
|
||||
"carries it, or copy the RetroRewind6 folder to ${GameStorage.modDirectory(context).absolutePath}."
|
||||
}
|
||||
GameFiles.validateData(disc)?.let { return it }
|
||||
val root = File(context.filesDir, "build")
|
||||
root.mkdirs()
|
||||
@@ -85,7 +90,7 @@ object GameBuild {
|
||||
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val log = BuildLog(File(GameStorage.logsDirectory(context), "build_$stamp.log"))
|
||||
try {
|
||||
return Build(context, root, disc, reporter, cancelled, finishing, log).run()
|
||||
return Build(context, profile, root, disc, reporter, cancelled, finishing, log).run()
|
||||
} catch (e: InterruptedIOException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -99,6 +104,7 @@ object GameBuild {
|
||||
|
||||
private class Build(
|
||||
val context: Context,
|
||||
val profile: GameProfile,
|
||||
val root: File,
|
||||
val disc: File,
|
||||
val reporter: Reporter,
|
||||
@@ -109,24 +115,26 @@ object GameBuild {
|
||||
val toolchain = File(root, "toolchain")
|
||||
val ndk = File(root, "ndk")
|
||||
val kit = File(root, "kit")
|
||||
val work = File(root, GameLibrary.directory(context).name)
|
||||
val work = File(root, profile.id)
|
||||
val workspace = File(work, "ws")
|
||||
val objects = File(work, "obj")
|
||||
val temporary = File(root, "tmp")
|
||||
val failure = AtomicReference<String?>(null)
|
||||
|
||||
fun run(): String? {
|
||||
log.line("WiiCompiled Quest ${BuildConfig.VERSION_NAME} building ${BuildConfig.PROFILE} on this headset")
|
||||
log.line("WiiCompiled Quest ${BuildConfig.VERSION_NAME} building ${profile.id} on this headset")
|
||||
report(0, Step.Prepare)
|
||||
val toolchainManifest = JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/toolchain.json").reader().use { it.readText() })
|
||||
unpackToolchain(toolchainManifest)
|
||||
report(30, Step.Prepare)
|
||||
val kitFingerprint = GameLibrary.kitFingerprint(context) ?: return "This app carries no game kit."
|
||||
unpackKit(kitFingerprint)
|
||||
val recipe = JSONObject(File(kit, "kit.json").readText())
|
||||
if (recipe.optInt("schema") != KIT_SCHEMA) {
|
||||
return "This app's game kit is version ${recipe.optInt("schema")}, which this builder does not know."
|
||||
val kitJson = JSONObject(File(kit, "kit.json").readText())
|
||||
if (kitJson.optInt("schema") != KIT_SCHEMA) {
|
||||
return "This app's game kit is version ${kitJson.optInt("schema")}, which this builder does not know."
|
||||
}
|
||||
val recipe = kitJson.getJSONObject("products").optJSONObject(profile.id)
|
||||
?: return "This app's game kit cannot build ${profile.id}."
|
||||
report(40, Step.Prepare)
|
||||
downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() }))
|
||||
|
||||
@@ -141,8 +149,9 @@ object GameBuild {
|
||||
log,
|
||||
cancelled = { cancelled() || failure.get() != null },
|
||||
)
|
||||
translate(tools, "$kitFingerprint ${toolchainManifest.getString("fingerprint")}")?.let { return it }
|
||||
val objectsBySlot = compile(tools, recipe, toolchainManifest.getString("fingerprint"))
|
||||
val buildIdentity = "$kitFingerprint ${toolchainManifest.getString("fingerprint")}"
|
||||
translate(tools, buildIdentity)?.let { return it }
|
||||
val objectsBySlot = compile(tools, recipe, buildIdentity)
|
||||
failure.get()?.let { return it }
|
||||
val library = link(tools, recipe, objectsBySlot) ?: return failure.get() ?: "Linking the game failed."
|
||||
|
||||
@@ -271,7 +280,10 @@ object GameBuild {
|
||||
fun translate(tools: ToolProcess, identity: String): String? {
|
||||
val generated = File(workspace, "generated")
|
||||
val provenance = File(generated, "translation-provenance.txt")
|
||||
val expected = "$identity ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256}"
|
||||
// A modded game needs a base translation that knows this Code.pul, so the pack's own
|
||||
// identity is part of what the stored translation is reused for.
|
||||
val modIdentity = if (profile.modPack) BuildRecipe.hex(sha256(GameStorage.modCodePul(context))) else ""
|
||||
val expected = "$identity ${profile.id} ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256} $modIdentity"
|
||||
val shards = File(generated, "build_shards/shards.cmake")
|
||||
if (provenance.isFile && provenance.readText() == expected && shards.isFile) {
|
||||
log.line("Reusing the translation of an earlier build")
|
||||
@@ -286,9 +298,17 @@ object GameBuild {
|
||||
File(disc, DiscChecks.DOL_PATH).copyTo(File(workspace, "Assets/main.dol"), overwrite = true)
|
||||
File(disc, DiscChecks.REL_PATH).copyTo(File(workspace, "Assets/StaticR.rel"), overwrite = true)
|
||||
val manifest = "projects/mkwii/recomp.yml"
|
||||
val entryPoint = BuildRecipe.entryPoint(File(workspace, manifest).readText())
|
||||
val manifestText = File(workspace, manifest).readText()
|
||||
val entryPoint = BuildRecipe.entryPoint(manifestText)
|
||||
// The base translation only knows a mod's patches when the profile's Code.pul sits
|
||||
// where recomp.yml's mod_root says, so the pack's copy is staged there first.
|
||||
val steps = if (profile.modPack) 4 else 3
|
||||
if (profile.modPack) {
|
||||
val modRoot = File(workspace, BuildRecipe.modRoot(manifestText))
|
||||
GameStorage.modCodePul(context).copyTo(File(modRoot, "Binaries/Code.pul"), overwrite = true)
|
||||
}
|
||||
|
||||
report(50, Step.Translate, 1, 3)
|
||||
report(50, Step.Translate, 1, steps)
|
||||
val timing = Regex("""\(t=([0-9.]+)s\)""")
|
||||
translator(
|
||||
tools, "translating the game",
|
||||
@@ -299,22 +319,66 @@ object GameBuild {
|
||||
) { line ->
|
||||
timing.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { seconds ->
|
||||
val fraction = minOf(seconds / EXPECTED_TRANSLATION_SECONDS, 0.97)
|
||||
reporter.update(50 + (320 * fraction).toInt(), Step.Translate, 1, 3)
|
||||
reporter.update(50 + (300 * fraction).toInt(), Step.Translate, 1, steps)
|
||||
}
|
||||
}?.let { return it }
|
||||
report(370, Step.Translate, 2, 3)
|
||||
|
||||
// Retro Rewind's own code: its Code.pul translated against the base translation, as
|
||||
// Launcher/LocalBuild.ps1 does on a PC. Online play needs a payload this cannot fetch.
|
||||
val modOutput = "build/mods/retro_rewind_full_cpp"
|
||||
if (profile.modPack) {
|
||||
report(350, Step.Translate, 2, steps)
|
||||
translator(
|
||||
tools, "creating the base manifest",
|
||||
"emit-base-manifest", "--project", manifest, "--out", "build/base",
|
||||
"--functions-dir", "generated/functions",
|
||||
"--translation-output-metadata", "generated/base_translation_output.json", "--region", "P",
|
||||
)?.let { return it }
|
||||
translator(
|
||||
tools, "translating Retro Rewind",
|
||||
"translate-mod", "--project", manifest, "--profile", "retro-rewind",
|
||||
"--base-manifest", "build/base/mkwii_base_manifest.json",
|
||||
"--base-translation-output-metadata", "generated/base_translation_output.json",
|
||||
"--code-pul", GameStorage.modCodePul(context).absolutePath,
|
||||
"--mod-root", GameStorage.modDirectory(context).absolutePath,
|
||||
"--mod-name", "Retro Rewind", "--region", "P", "--out", modOutput,
|
||||
"--prefer-cached-inputs", "--emit-cpp", "--skip-retro-wfc",
|
||||
"--threads", TRANSLATOR_THREADS.toString(),
|
||||
)?.let { return it }
|
||||
}
|
||||
|
||||
report(370, Step.Translate, steps - 1, steps)
|
||||
translator(tools, "generating the game data", "generate-data-init", "--project", manifest, "--target-os", "android")?.let { return it }
|
||||
report(385, Step.Translate, 3, 3)
|
||||
translator(
|
||||
tools, "preparing the build",
|
||||
report(385, Step.Translate, steps, steps)
|
||||
val shardArguments = mutableListOf(
|
||||
"emit-build-shards", "--project", manifest, "--base-metadata", "generated/base_translation_output.json",
|
||||
"--base-functions-dir", "generated/functions", "--native-source-dir", "runtime/src", "--out", "generated/build_shards",
|
||||
)?.let { return it }
|
||||
)
|
||||
if (profile.modPack) {
|
||||
shardArguments += listOf(
|
||||
"--resolved-profile", "$modOutput/resolved_dispatch_profile.json",
|
||||
"--retro-cpp-dir", "$modOutput/cpp",
|
||||
)
|
||||
}
|
||||
translator(tools, "preparing the build", *shardArguments.toTypedArray())?.let { return it }
|
||||
provenance.writeText(expected)
|
||||
report(400, Step.Translate, 3, 3)
|
||||
report(400, Step.Translate, steps, steps)
|
||||
return null
|
||||
}
|
||||
|
||||
fun sha256(file: File): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest()
|
||||
}
|
||||
|
||||
fun translator(tools: ToolProcess, what: String, vararg arguments: String, onLine: (String) -> Unit = {}): String? {
|
||||
log.line("== translator ${arguments.first()}")
|
||||
val result = tools.run(
|
||||
@@ -332,11 +396,12 @@ object GameBuild {
|
||||
}
|
||||
|
||||
/** Compiles every generated source; returns their objects by link slot (runtime, product, translated). */
|
||||
fun compile(tools: ToolProcess, recipe: JSONObject, toolchainFingerprint: String): Map<String, List<String>> {
|
||||
// The identity is the kit's fingerprint and the toolchain's: the kit is fingerprinted as a
|
||||
// whole, and each game already compiles in its own work directory.
|
||||
fun compile(tools: ToolProcess, recipe: JSONObject, identity: String): Map<String, List<String>> {
|
||||
val generated = File(workspace, "generated")
|
||||
val shards = File(generated, "build_shards/shards.cmake").readText()
|
||||
val stamp = File(objects, ".stamp")
|
||||
val identity = "${recipe.getString("fingerprint")} $toolchainFingerprint"
|
||||
if (stamp.takeIf { it.isFile }?.readText() != identity) {
|
||||
objects.deleteRecursively()
|
||||
objects.mkdirs()
|
||||
@@ -481,7 +546,7 @@ object GameBuild {
|
||||
}
|
||||
|
||||
fun install(library: File, kitFingerprint: String): String? {
|
||||
val destination = GameLibrary.directory(context)
|
||||
val destination = GameLibrary.directory(context, profile)
|
||||
val staging = File(destination.parentFile, "${destination.name}.building")
|
||||
staging.deleteRecursively()
|
||||
staging.mkdirs()
|
||||
@@ -500,7 +565,7 @@ object GameBuild {
|
||||
val builtAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date())
|
||||
val manifest = JSONObject()
|
||||
.put("schema", 1)
|
||||
.put("profile", BuildConfig.PROFILE)
|
||||
.put("profile", profile.id)
|
||||
.put("gameId", BuildConfig.DISC_GAME_ID)
|
||||
.put("dolSha256", BuildConfig.DISC_DOL_SHA256)
|
||||
.put("relSha256", BuildConfig.DISC_REL_SHA256)
|
||||
|
||||
@@ -15,22 +15,25 @@ import java.util.zip.ZipInputStream
|
||||
import org.json.JSONObject
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
|
||||
/**
|
||||
* A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest")
|
||||
* becomes this headset's game: libmain.so and game.json, and DATA too when the package carries
|
||||
* the extracted disc.
|
||||
* becomes one of this headset's games: libmain.so and game.json, plus DATA when the package
|
||||
* carries the extracted disc and the Retro Rewind pack when it carries that.
|
||||
*
|
||||
* The package is only accepted for the disc and app it was built for: game.json must name the
|
||||
* pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
|
||||
* in the package. Both parts are staged and checked before either replaces what is installed.
|
||||
* The package says which game it is (`profile`), so importing one installs that game and leaves
|
||||
* the other alone. It is only accepted for the disc and app it was built for: game.json must name
|
||||
* the pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
|
||||
* in the package. Every part is staged and checked before any of it replaces what is installed.
|
||||
*/
|
||||
object GamePackageImport {
|
||||
|
||||
private const val TAG = "WiiCompiledLauncher"
|
||||
private const val SCHEMA = 1
|
||||
private const val DATA_PREFIX = "DATA/"
|
||||
private const val MOD_PREFIX = "MOD/"
|
||||
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
|
||||
|
||||
/** Null on success, otherwise the message to show. */
|
||||
@@ -42,21 +45,37 @@ object GamePackageImport {
|
||||
return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free."
|
||||
}
|
||||
|
||||
val libraryDir = GameLibrary.directory(context)
|
||||
val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing")
|
||||
// The game's destination is only known once game.json has been read, so the library is
|
||||
// staged under a name of its own first.
|
||||
val libraryStaging = File(context.filesDir, "game/.importing")
|
||||
val dataStaging = File(gameRoot, "DATA.importing")
|
||||
libraryStaging.deleteRecursively()
|
||||
dataStaging.deleteRecursively()
|
||||
val modStaging = File(gameRoot, "${GameStorage.MOD_DIRECTORY}.importing")
|
||||
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
|
||||
try {
|
||||
libraryStaging.mkdirs()
|
||||
var manifestText: String? = null
|
||||
var librarySha256: String? = null
|
||||
var hasData = false
|
||||
var hasMod = false
|
||||
|
||||
val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor))
|
||||
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
var lastReport = 0L
|
||||
fun extract(target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (counted.count - lastReport >= 8L shl 20) {
|
||||
lastReport = counted.count
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
val name = entry.name
|
||||
@@ -80,19 +99,11 @@ object GamePackageImport {
|
||||
}
|
||||
name.startsWith(DATA_PREFIX) -> {
|
||||
hasData = true
|
||||
val target = File(dataStaging, name.removePrefix(DATA_PREFIX))
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (counted.count - lastReport >= 8L shl 20) {
|
||||
lastReport = counted.count
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
extract(File(dataStaging, name.removePrefix(DATA_PREFIX)))
|
||||
}
|
||||
name.startsWith(MOD_PREFIX) -> {
|
||||
hasMod = true
|
||||
extract(File(modStaging, name.removePrefix(MOD_PREFIX)))
|
||||
}
|
||||
}
|
||||
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
|
||||
@@ -102,24 +113,34 @@ object GamePackageImport {
|
||||
finishing()
|
||||
val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?: return "This is not a WiiCompiled game file (game.json is missing)."
|
||||
val profile = GameProfile.of(manifestJson.optString("profile"))
|
||||
?.takeIf { it in GameProfile.available(context) }
|
||||
?: return "This game file is for ${manifestJson.optString("profile")}, which this app does not carry a kit for."
|
||||
manifestError(context, manifestJson, librarySha256)?.let { return it }
|
||||
if (hasData) {
|
||||
GameFiles.validateData(dataStaging)?.let { return it }
|
||||
} else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
|
||||
return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first."
|
||||
}
|
||||
if (hasMod && !File(modStaging, "Binaries/Code.pul").isFile) {
|
||||
return "The Retro Rewind pack in this game file is incomplete (no Binaries/Code.pul)."
|
||||
}
|
||||
|
||||
File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!)
|
||||
if (hasData) {
|
||||
GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it }
|
||||
}
|
||||
if (hasMod) {
|
||||
GameFiles.replace(GameStorage.modDirectory(context), modStaging)?.let { return it }
|
||||
}
|
||||
val libraryDir = GameLibrary.directory(context, profile)
|
||||
libraryDir.parentFile?.mkdirs()
|
||||
GameFiles.replace(libraryDir, libraryStaging)?.let { return it }
|
||||
Log.i(TAG, "Imported the game built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
|
||||
Log.i(TAG, "Imported ${profile.id} built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
|
||||
GameProfile.select(context, profile)
|
||||
return null
|
||||
} finally {
|
||||
libraryStaging.deleteRecursively()
|
||||
dataStaging.deleteRecursively()
|
||||
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +148,6 @@ object GamePackageImport {
|
||||
if (manifest.optInt("schema") != SCHEMA) {
|
||||
return "This game file comes from a newer or older launcher (format ${manifest.optInt("schema")}). Build it again with the current PC launcher."
|
||||
}
|
||||
if (manifest.optString("profile") != BuildConfig.PROFILE) {
|
||||
return "This game file is for ${manifest.optString("profile")}, but this app is ${BuildConfig.PROFILE}."
|
||||
}
|
||||
if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) ||
|
||||
!manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) ||
|
||||
!manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.InterruptedIOException
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
|
||||
/**
|
||||
* The long-running steps that make the game playable from what the player owns, one at a time,
|
||||
@@ -87,7 +88,7 @@ object GameSetup {
|
||||
}
|
||||
|
||||
/** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. [uri] is null only for a build. */
|
||||
fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean) {
|
||||
fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean, profile: GameProfile = GameProfile.selected(context)) {
|
||||
val progress = Progress { done, total ->
|
||||
publish(State.Working(task, done, total))
|
||||
!cancelRequested
|
||||
@@ -99,7 +100,7 @@ object GameSetup {
|
||||
publish(State.Working(task, permille.toLong(), 1000, step, done, total))
|
||||
!cancelRequested
|
||||
}
|
||||
GameBuild.run(context, reporter, cancelled = { cancelRequested }, finishing)
|
||||
GameBuild.run(context, profile, reporter, cancelled = { cancelRequested }, finishing)
|
||||
} else {
|
||||
uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor ->
|
||||
when (task) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlin.concurrent.thread
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
@@ -51,13 +52,14 @@ class GameSetupService : Service() {
|
||||
}
|
||||
GameSetup.addListener(listener)
|
||||
val deleteSource = intent?.getBooleanExtra(EXTRA_DELETE_SOURCE, false) ?: false
|
||||
val profile = GameProfile.of(intent?.getStringExtra(EXTRA_PROFILE)) ?: GameProfile.selected(this)
|
||||
|
||||
val wakeLock = getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
|
||||
wakeLock.acquire(if (task == GameSetup.Task.BuildGame) BUILD_WAKE_LOCK_TIMEOUT_MS else WAKE_LOCK_TIMEOUT_MS)
|
||||
thread(name = "GameSetup") {
|
||||
try {
|
||||
GameSetup.run(applicationContext, task, uri, deleteSource)
|
||||
GameSetup.run(applicationContext, task, uri, deleteSource, profile)
|
||||
} finally {
|
||||
uri?.let(::releaseReadPermission)
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
@@ -115,21 +117,24 @@ class GameSetupService : Service() {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFY_INTERVAL_MS = 1000L
|
||||
private const val EXTRA_TASK = "task"
|
||||
private const val EXTRA_PROFILE = "profile"
|
||||
private const val EXTRA_DELETE_SOURCE = "deleteSource"
|
||||
// Longer than any real task; only a hung run would reach it.
|
||||
private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000
|
||||
private const val BUILD_WAKE_LOCK_TIMEOUT_MS = 3L * 60 * 60 * 1000
|
||||
|
||||
/** Builds [profile]'s game from DATA on this headset ([GameBuild]). */
|
||||
fun startBuild(context: Context, profile: GameProfile) {
|
||||
val intent = Intent(context, GameSetupService::class.java)
|
||||
.putExtra(EXTRA_TASK, GameSetup.Task.BuildGame.name)
|
||||
.putExtra(EXTRA_PROFILE, profile.id)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts [task] on [uri]: a document the player picked, or a file in the Import folder,
|
||||
* which [deleteSource] removes once it has been imported.
|
||||
*/
|
||||
/** Builds the game from DATA on this headset ([GameBuild]). */
|
||||
fun startBuild(context: Context) {
|
||||
val intent = Intent(context, GameSetupService::class.java).putExtra(EXTRA_TASK, GameSetup.Task.BuildGame.name)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) {
|
||||
if (uri.scheme == "content") {
|
||||
try {
|
||||
|
||||
@@ -11,12 +11,15 @@ import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.QuestActivity
|
||||
import org.wiicompiled.quest.R
|
||||
@@ -56,8 +59,14 @@ class LauncherActivity : Activity() {
|
||||
private lateinit var dataBanner: View
|
||||
private lateinit var dataBannerIcon: ImageView
|
||||
private lateinit var dataBannerText: TextView
|
||||
private lateinit var homeTitle: TextView
|
||||
private lateinit var gameToggle: LinearLayout
|
||||
private lateinit var settings: SettingsPage
|
||||
|
||||
/** The games this APK carries a kit for, and the one the player picked. */
|
||||
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
|
||||
private var profile = GameProfile.Base
|
||||
|
||||
private var page = Page.Home
|
||||
private var launching = false
|
||||
private var trailsAway = true
|
||||
@@ -99,9 +108,9 @@ class LauncherActivity : Activity() {
|
||||
dataBannerIcon = findViewById(R.id.home_data_banner_icon)
|
||||
dataBannerText = findViewById(R.id.home_data_banner_text)
|
||||
|
||||
findViewById<TextView>(R.id.home_title).setText(
|
||||
if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base,
|
||||
)
|
||||
homeTitle = findViewById(R.id.home_title)
|
||||
gameToggle = findViewById(R.id.home_game_toggle)
|
||||
buildGameToggle()
|
||||
findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME)
|
||||
|
||||
settings = SettingsPage(
|
||||
@@ -131,13 +140,15 @@ class LauncherActivity : Activity() {
|
||||
!GameSetup.isRunning && GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready
|
||||
) {
|
||||
Log.i(TAG, "Starting a game build requested over adb")
|
||||
GameSetupService.startBuild(this)
|
||||
GameSetupService.startBuild(this, GameProfile.selected(this))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
launching = false
|
||||
// An import can install the other game and select it, so the toggle follows the file.
|
||||
profile = GameProfile.selected(this)
|
||||
importDroppedPackage()
|
||||
setupKind = GameSetup.state.javaClass
|
||||
refresh()
|
||||
@@ -198,7 +209,11 @@ class LauncherActivity : Activity() {
|
||||
private fun refreshHome() {
|
||||
val disc = GameStorage.discDirectory(this).absolutePath
|
||||
val discStatus = GameStorage.discStatus(this)
|
||||
val gameStatus = GameLibrary.status(this)
|
||||
val gameStatus = GameLibrary.status(this, profile)
|
||||
homeTitle.setText(profile.title)
|
||||
for ((index, entry) in profiles.withIndex()) {
|
||||
gameToggle.getChildAt(index).isSelected = entry == profile
|
||||
}
|
||||
val running = isGameRunning()
|
||||
val setup = GameSetup.state
|
||||
val settingUp = GameSetup.isRunning
|
||||
@@ -211,7 +226,7 @@ class LauncherActivity : Activity() {
|
||||
mainAction = when {
|
||||
running -> Action.Resume
|
||||
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
|
||||
gameStatus != GameLibrary.Status.Ready -> if (BuildConfig.ON_DEVICE_BUILD) Action.BuildGame else Action.ImportGame
|
||||
gameStatus != GameLibrary.Status.Ready -> Action.BuildGame
|
||||
else -> Action.Play
|
||||
}
|
||||
secondaryAction = when {
|
||||
@@ -280,14 +295,12 @@ class LauncherActivity : Activity() {
|
||||
setup is GameSetup.State.Failed && building -> showBanner(getString(R.string.home_build_failed, setup.message), warning = true)
|
||||
setup is GameSetup.State.Failed -> showBanner(getString(R.string.home_setup_failed, setup.message), warning = true)
|
||||
discStatus == GameStorage.DiscStatus.Incomplete -> showBanner(getString(R.string.home_data_incomplete, disc), warning = true)
|
||||
!GameStorage.modContentReady(this) ->
|
||||
!GameStorage.modContentReady(this, profile) ->
|
||||
showBanner(getString(R.string.home_mod_missing, GameStorage.modDirectory(this).absolutePath), warning = true)
|
||||
gameStatus == GameLibrary.Status.Stale ->
|
||||
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_stale else R.string.home_game_stale_pc), warning = true)
|
||||
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
|
||||
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
|
||||
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_setup_intro else R.string.home_setup_intro_pc), warning = false)
|
||||
gameStatus == GameLibrary.Status.Missing ->
|
||||
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_missing else R.string.home_game_missing_pc), warning = false)
|
||||
showBanner(getString(R.string.home_setup_intro), warning = false)
|
||||
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
|
||||
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
|
||||
else -> showBanner(null)
|
||||
}
|
||||
@@ -298,6 +311,40 @@ class LauncherActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home's game switch, shown only when this APK carries more than one kit. Each game keeps its
|
||||
* own built library, so switching is immediate; only the panel's state changes.
|
||||
*/
|
||||
private fun buildGameToggle() {
|
||||
for (entry in profiles) {
|
||||
val button = TextView(this).apply {
|
||||
setText(entry.title)
|
||||
textSize = 14f
|
||||
setTextColor(getColorStateList(R.color.game_toggle_text))
|
||||
setBackgroundResource(R.drawable.bg_game_toggle)
|
||||
gravity = android.view.Gravity.CENTER
|
||||
// One line: the pill has a fixed height, so a wrapped "Retro Rewind" loses half of itself.
|
||||
isSingleLine = true
|
||||
setPadding(10.dp(), 0, 10.dp(), 0)
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 38.dp())
|
||||
setOnClickListener { selectProfile(entry) }
|
||||
}
|
||||
gameToggle.addView(button)
|
||||
}
|
||||
gameToggle.visibility = if (profiles.size > 1) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun selectProfile(target: GameProfile) {
|
||||
if (target == profile || GameSetup.isRunning) return
|
||||
profile = target
|
||||
GameProfile.select(this, target)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun label(action: Action): Int = when (action) {
|
||||
Action.Play -> R.string.home_play
|
||||
Action.Resume -> R.string.home_resume
|
||||
@@ -325,12 +372,20 @@ class LauncherActivity : Activity() {
|
||||
GameBuild.Step.Prepare, null -> getString(R.string.build_step_prepare)
|
||||
}
|
||||
|
||||
/** Builds the game on this headset from DATA, after saying what that takes. */
|
||||
/** Builds the selected game on this headset from DATA, after saying what that takes. */
|
||||
private fun buildGame() {
|
||||
if (!BuildConfig.ON_DEVICE_BUILD || GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
|
||||
val message = if (GameLibrary.status(this) == GameLibrary.Status.Ready) R.string.home_build_replace_message else R.string.home_build_message
|
||||
if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
|
||||
if (!GameStorage.modContentReady(this, profile)) {
|
||||
confirm(R.string.home_mod_needed_title, R.string.home_mod_needed_message, R.string.home_import) { importGame() }
|
||||
return
|
||||
}
|
||||
val message = if (GameLibrary.status(this, profile) == GameLibrary.Status.Ready) {
|
||||
R.string.home_build_replace_message
|
||||
} else {
|
||||
R.string.home_build_message
|
||||
}
|
||||
confirm(R.string.home_build_title, message, R.string.home_build) {
|
||||
GameSetupService.startBuild(this)
|
||||
GameSetupService.startBuild(this, profile)
|
||||
showPage(Page.Home)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +417,7 @@ class LauncherActivity : Activity() {
|
||||
/** Opens the document picker for a .wcgame, asking first when it would replace the game. */
|
||||
private fun importGame() {
|
||||
if (GameSetup.isRunning) return
|
||||
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
|
||||
if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
|
||||
openPicker(REQUEST_GAME_PACKAGE)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.widget.Toast
|
||||
import kotlin.math.roundToInt
|
||||
import org.wiicompiled.quest.BuildConfig
|
||||
import org.wiicompiled.quest.GameLibrary
|
||||
import org.wiicompiled.quest.GameProfile
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
@@ -260,7 +261,7 @@ class SettingsPage(
|
||||
|
||||
private fun buildAbout() {
|
||||
section(R.string.section_about_app) {
|
||||
info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE))
|
||||
info(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
section(R.string.section_about_storage) {
|
||||
val disc = GameStorage.discDirectory(activity).absolutePath
|
||||
@@ -273,15 +274,18 @@ class SettingsPage(
|
||||
action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) {
|
||||
selectDiscImage()
|
||||
}
|
||||
val manifest = GameLibrary.manifest(activity)
|
||||
val gameText = when (GameLibrary.status(activity)) {
|
||||
GameLibrary.Status.Ready -> manifest?.let {
|
||||
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
|
||||
} ?: activity.getString(R.string.about_game_missing)
|
||||
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
|
||||
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
|
||||
// One row per game this app carries a kit for, so both are visible at once.
|
||||
for (profile in GameProfile.available(activity)) {
|
||||
val manifest = GameLibrary.manifest(activity, profile)
|
||||
val gameText = when (GameLibrary.status(activity, profile)) {
|
||||
GameLibrary.Status.Ready -> manifest?.let {
|
||||
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
|
||||
} ?: activity.getString(R.string.about_game_missing)
|
||||
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
|
||||
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
|
||||
}
|
||||
info(profile.title, gameText, stacked = true)
|
||||
}
|
||||
info(R.string.about_game, gameText, stacked = true)
|
||||
if (BuildConfig.ON_DEVICE_BUILD) {
|
||||
action(
|
||||
R.string.home_build, R.string.about_build_helper, R.string.home_build,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/neutral_950" android:state_selected="true" />
|
||||
<item android:color="@color/neutral_100" android:state_hovered="true" />
|
||||
<item android:color="@color/neutral_300" />
|
||||
</selector>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- One side of Home's game toggle: the selected game is filled, as WheelWizard's game switch is. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary_400" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_700" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/neutral_800" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="@android:color/transparent" />
|
||||
</selector>
|
||||
@@ -90,6 +90,16 @@
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/home_game_toggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:background="@drawable/bg_pill"
|
||||
android:orientation="horizontal"
|
||||
android:padding="3dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/home_title"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -21,12 +21,11 @@
|
||||
<string name="home_put_on_headset">The game opens in the headset.</string>
|
||||
<string name="home_data_missing">The game is ready, but its game files (DATA) are missing. Select your own PAL (RMCP01) disc image to extract them, or import a game file from your computer that includes them.\n\nThe game files go to:\n%1$s</string>
|
||||
<string name="home_import">Import from computer</string>
|
||||
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer.</string>
|
||||
<string name="home_game_missing">The game files are ready. Now build the game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string>
|
||||
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: each game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer. Retro Rewind also needs its pack, which a Retro Rewind game file can carry.</string>
|
||||
<string name="home_game_missing">The game files are ready. Now build this game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string>
|
||||
<string name="home_game_stale">This app was updated, so the game must be built again for it: press Build on this Quest, or use Build for Quest in the WiiCompiled VR launcher again and Import from computer.</string>
|
||||
<string name="home_setup_intro_pc">Nothing from Mario Kart Wii or Retro Rewind comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nCopy your disc image (ISO, WBFS, RVZ…) here, for example into Download, and press Select disc image. Then, on a computer, use Build for Quest in the WiiCompiled VR launcher with this app’s APK, copy the .wcgame file it makes to this headset and press Import from computer.</string>
|
||||
<string name="home_game_missing_pc">The game files are ready. The game itself is built on a computer: in the WiiCompiled VR launcher, use Build for Quest with this app’s APK, copy the .wcgame file to this headset, then press Import from computer.</string>
|
||||
<string name="home_game_stale_pc">This app was updated, so the game must be built again for it: use Build for Quest in the WiiCompiled VR launcher again, then Import from computer.</string>
|
||||
<string name="home_mod_needed_title">Retro Rewind needs its pack</string>
|
||||
<string name="home_mod_needed_message">Building Retro Rewind needs its pack (the RetroRewind6 folder) on this headset. Import a Retro Rewind game file built with the pack included, or copy the folder next to DATA yourself.</string>
|
||||
<string name="home_build">Build on this Quest</string>
|
||||
<string name="home_build_preparing">Preparing…</string>
|
||||
<string name="home_building">Building %1$d%%</string>
|
||||
@@ -165,7 +164,6 @@
|
||||
<!-- About tab -->
|
||||
<string name="section_about_app">App</string>
|
||||
<string name="about_version">Version</string>
|
||||
<string name="about_version_value">%1$s (%2$s)</string>
|
||||
<string name="section_about_storage">Files</string>
|
||||
<string name="about_game_data">Game data</string>
|
||||
<string name="about_game_data_ready">Found: %1$s</string>
|
||||
|
||||
@@ -11,6 +11,9 @@ import org.junit.Test
|
||||
*/
|
||||
class TomlConfigTest {
|
||||
|
||||
private val dvdRoot =
|
||||
"dvd_root = \"/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\""
|
||||
|
||||
private val sample = """
|
||||
# WiiCompiled Quest configuration.
|
||||
[paths]
|
||||
@@ -50,6 +53,21 @@ class TomlConfigTest {
|
||||
assertEquals(expected, config.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addsTheRetroRewindPackPathToAConfigThatPredatesIt() {
|
||||
// What GameStorage.prepare does to an install made before the app offered Retro Rewind:
|
||||
// the pack path joins [paths], and nothing else moves.
|
||||
val config = TomlConfig.parse(sample)
|
||||
assertNull(config.string("paths", "retro_rewind_root"))
|
||||
val pack = "/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/RetroRewind6"
|
||||
config.setString("paths", "retro_rewind_root", pack)
|
||||
assertEquals(
|
||||
sample.replace("$dvdRoot\n\n[video]", "$dvdRoot\nretro_rewind_root = \"$pack\"\n\n[video]"),
|
||||
config.text(),
|
||||
)
|
||||
assertEquals(pack, TomlConfig.parse(config.text()).string("paths", "retro_rewind_root"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendsAMissingSection() {
|
||||
val config = TomlConfig.parse(sample)
|
||||
|
||||
+58
-32
@@ -223,8 +223,8 @@ The kit is exported from CMake's own build graph, so its flags cannot drift from
|
||||
- `runtime/cmake/PublicProducts.cmake` defines `mkw_quest_kit_probe` on Android: WiiCompiled
|
||||
`WITHOUT_GAME` (no base shards, and the runtime objects `$<FILTER>`ed of the two
|
||||
disc-generated sources, which skip the unity build on Android for that reason), linked with the
|
||||
game's symbols unresolved. The base flavour builds this probe instead of the product.
|
||||
- `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `exportBase*QuestGameKit`
|
||||
game's symbols unresolved. The app builds this probe instead of the product.
|
||||
- `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `export*QuestGameKit`
|
||||
Gradle tasks) turns the probe's ninja link edge into `kit.json`'s ordered link inputs. It adds
|
||||
`{game:runtime}`, `{game:product}` and `{game:translated}` markers where WiiCompiled had those
|
||||
objects, and translated shards link inside `--start-lib/--end-lib` with archive semantics as
|
||||
@@ -234,40 +234,56 @@ The kit is exported from CMake's own build graph, so its flags cannot drift from
|
||||
this way had the same 61,975 defined and 785 undefined dynamic symbols, 29,995 translated
|
||||
functions, `NEEDED` list and soname as the CMake-built one, in 2.4 minutes.
|
||||
|
||||
`Build-Quest.ps1` refuses an APK that contains any `libmain*.so` or lacks the kit, and every
|
||||
variant excludes `**/libmain*.so` and the probes from packaging, since AGP packages every library
|
||||
`Build-Quest.ps1` refuses an APK that contains any `libmain*.so` or lacks the kit, and packaging
|
||||
excludes `**/libmain*.so` and `**/libmkw_quest_kit_probe*.so`, since AGP packages every library
|
||||
left in the CMake output directory. The `func_8…` symbols the kit's runtime objects define are
|
||||
hand-written HLE overrides (`PPC_NATIVE_OVERRIDE_*` in `hle_stubs.h`), not translated code.
|
||||
|
||||
**Retro Rewind has its own kit** (`mkw_quest_kit_probe_retro`, RetroRewind without any translated
|
||||
code), so that APK ships no game either. It needs one more link slot than the base flavour, because
|
||||
the modded product links more kinds of translated code: `{game:runtime}` (the disc-generated
|
||||
sources), `{game:product}` (the mod's registration and dispatch shards), `{game:mod}` (the mod's own
|
||||
shards, the profile-sensitive base shards it replaces, and the mod's data patches) and
|
||||
`{game:translated}` (the shared base shards, with the archive semantics `libmkw_base_shared.a` has).
|
||||
Rather than teach each builder which flavour it is building, `kit.json` now carries a `sources`
|
||||
map naming the `shards.cmake` list behind each slot, and the builders just follow it (kit schema 2).
|
||||
Its game is built on a PC for now: building it on the headset would also need the mod's `Code.pul`
|
||||
and its 2 GB pack there, so only the base APK carries the on-device toolchain
|
||||
(`BuildConfig.ON_DEVICE_BUILD`). The Retro Rewind app writes `[paths] retro_rewind_root` into its
|
||||
first `Config.toml` and Home says so when that pack is missing.
|
||||
**Retro Rewind rides in the same app, on its own kit** (`mkw_quest_kit_probe_retro`, RetroRewind
|
||||
without any translated code), so the APK ships neither game. The mod needs one more link slot than
|
||||
the base game, because the modded product links more kinds of translated code: `{game:runtime}`
|
||||
(the disc-generated sources), `{game:product}` (the mod's registration and dispatch shards),
|
||||
`{game:mod}` (the mod's own shards, the profile-sensitive base shards it replaces, and the mod's
|
||||
data patches) and `{game:translated}` (the shared base shards, with the archive semantics
|
||||
`libmkw_base_shared.a` has). Rather than teach each builder which game it is building, `kit.json`
|
||||
carries a `sources` map naming the `shards.cmake` list behind each slot, and the builders just
|
||||
follow it.
|
||||
|
||||
One APK, one kit: `kit.json` (schema 3) holds a `products` map with a `base` entry and, when the
|
||||
translation includes the mod, a `retro_rewind` entry, each with its own compile flags, link line,
|
||||
slots and `fingerprint`; `runtimeIncludeFingerprint` and the kit's own `fingerprint` stay at the
|
||||
top level. Everything that builds a game names the product it wants: `Build-QuestGame.ps1
|
||||
-Product base|retro_rewind`, `Setup --quest-product`, and on the headset `GameProfile`. Each game
|
||||
lives in its own directory under `files/game/<profile>`, so both can be installed at once and the
|
||||
launcher's toggle switches between them; the selected one is remembered in `filesDir/selected-game`
|
||||
(a plain file, because the launcher and the `:game` process do not share preferences).
|
||||
|
||||
Retro Rewind also needs its 2 GB pack on the headset. The app writes `[paths] retro_rewind_root`
|
||||
into `Config.toml`, Home says so when the pack is missing, and a `.wcgame` can carry it (below).
|
||||
Building the mod on the headset additionally needs the mod's `Code.pul`, which arrives with that
|
||||
pack, so **Build on this Quest** offers Retro Rewind only once the pack is installed.
|
||||
|
||||
### Game packages (.wcgame) and Import from computer
|
||||
|
||||
A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted
|
||||
disc, written without compression). `game.json` records the profile, game ID, `main.dol` and
|
||||
`StaticR.rel` pins, the kit fingerprint, the library's SHA-256 and who built it.
|
||||
`android/Build-QuestGame.ps1` builds one on a PC from the translator's output and the kit the
|
||||
last APK build exported, and `-Data` includes the game files. `-Install` pushes it into the app's
|
||||
`Import` folder. The launcher creates that folder itself so it owns it, and imports the newest
|
||||
package the next time it opens, once per package.
|
||||
disc) and `MOD/…` (the RetroRewind6 pack), both written without compression. `game.json` records
|
||||
the profile, game ID, `main.dol` and `StaticR.rel` pins, the kit fingerprint, the library's
|
||||
SHA-256, whether the package carries game files and mod content, and who built it.
|
||||
`android/Build-QuestGame.ps1 -Product base|retro_rewind` builds one on a PC from the translator's
|
||||
output and the kit the last APK build exported; `-Data` includes the game files and `-Mod
|
||||
<RetroRewind6>` the pack. `-Install` pushes it into the app's `Import` folder. The launcher
|
||||
creates that folder itself so it owns it, and imports the newest package the next time it opens,
|
||||
once per package. An import selects the game it just installed.
|
||||
|
||||
Players get the same build from WheelWizard VR: Settings → WiiCompiled → Meta Quest → **Build**.
|
||||
WheelWizard asks for the Quest app's APK, whether to include the game files and where to save the
|
||||
package, then runs the installed setup:
|
||||
WheelWizard asks which game, whether to include the game files, for Retro Rewind whether to
|
||||
include its pack, for the Quest app's APK and where to save the package, then runs the installed
|
||||
setup:
|
||||
|
||||
```
|
||||
WiiCompiled-Setup.exe --build-quest --install-dir <install> --quest-apk <app.apk> --output <file.wcgame> [--include-game-files] --progress-json
|
||||
WiiCompiled-Setup.exe --build-quest --install-dir <install> --quest-apk <app.apk> --output <file.wcgame>
|
||||
--quest-product base|retro_rewind [--include-game-files]
|
||||
[--retro-dir <RetroRewind6> --include-mod-content] --progress-json
|
||||
```
|
||||
|
||||
Setup extracts `assets/game_kit` from the APK into `<install>\QuestBuild\kit`, so the game always
|
||||
@@ -375,10 +391,11 @@ Android facts this design rests on, all measured on a Quest 3:
|
||||
also learned `--target-os windows|macos|linux|android` for
|
||||
`generate-data-init` and `translate-mod`, for pipelines that generate on
|
||||
another host.
|
||||
- `android/`: the Gradle project. `app/src/main/cpp/CMakeLists.txt` adds the
|
||||
repository's `runtime/` as a subdirectory with those Android choices;
|
||||
flavours `base` and `retroRewind` each build their own game kit probe, and only `base` carries
|
||||
the on-headset toolchain.
|
||||
- `android/`: the Gradle project, one app with no flavours.
|
||||
`app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a
|
||||
subdirectory with those Android choices and builds both game kit probes
|
||||
(the Retro Rewind one only when the translation includes the mod), which
|
||||
`exportDebugQuestGameKit` turns into the single kit the app carries.
|
||||
- `android/nod-jni`: Gradle's `buildNodJni` task runs `cargo build --release
|
||||
--locked --target aarch64-linux-android` with the NDK's clang as linker and C
|
||||
compiler. `stageNodJni` puts `libnod_jni.so` into the APK's `arm64-v8a`
|
||||
@@ -389,7 +406,7 @@ Android facts this design rests on, all measured on a Quest 3:
|
||||
Prerequisites on the Windows host (all already present on the machine this
|
||||
was developed on): JDK 17, Android SDK with platform 34+, NDK `29.0.14206865`,
|
||||
SDK CMake `3.22.1`, `adb`, Rust 1.85+ with `rustup target add aarch64-linux-android`,
|
||||
the .NET 10 SDK (for the headset's translator; the base APK build downloads ~70 MB of Termux
|
||||
the .NET 10 SDK (for the headset's translator; the APK build downloads ~70 MB of Termux
|
||||
packages into `android/.dependencies` the first time); a translated graph for your own disc (the
|
||||
installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline).
|
||||
|
||||
@@ -397,8 +414,7 @@ installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline)
|
||||
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest)
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind app and its kit (needs translate-mod output)
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Flavor retroRewind -Data <dir> -Install # its game, with the disc files
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output)
|
||||
adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
|
||||
```
|
||||
|
||||
@@ -468,6 +484,16 @@ What has been verified on the development machine (September 2026):
|
||||
the base library does not have. Packaged with the disc files (2.55 GB), imported on the Quest 3 in
|
||||
under two minutes, and the mod runs: its own title screen, "Press the A Button", and the licence
|
||||
menu, with the pack read from `retro_rewind_root`.
|
||||
- **Device, 2026-09-17: one app, both games.** The merged APK (121 MB) carries one kit with a `base`
|
||||
and a `retro_rewind` recipe, no `libmain*.so` and no probe. Both games were built on the PC from
|
||||
that one kit (base in 0.1 min from cached objects, Retro Rewind in 3.3 min) and imported on a
|
||||
Quest 3: the base package in 3 s, the Retro Rewind one — 2.0 GB, carrying the pack — in 93 s,
|
||||
which installed `RetroRewind6` and selected the game it had just installed. Both live side by
|
||||
side in `files/game/<profile>`. Retro Rewind ran first (its title screen), then Home's toggle
|
||||
switched to Mario Kart Wii, which ran from the same app. Two bugs this found: the on-device
|
||||
builder read a per-product `fingerprint` that schema 3 keeps at the kit's top level, and a
|
||||
`Config.toml` written before this app offered Retro Rewind named no pack, so the mod would have
|
||||
found none — `GameStorage.prepare` now adds that one line to an existing config.
|
||||
|
||||
Bring-up fixes that only a device could reveal:
|
||||
|
||||
|
||||
Reference in new issue
Block a user