From 1d8fcac35eb61814a74e047ef5006b819a8bda06 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Mon, 21 Sep 2026 02:34:31 +0200 Subject: [PATCH] Scan GX_DEFERRED overrides, and guard the Retro-WFC payload The native-registration scan only knew PPC_NATIVE_OVERRIDE, so every GX_DEFERRED_OVERRIDE_VOID was missing from the index; the deferred form registers `symbol` and posts the hand-written `symbol_gx`. A Retro Rewind kit built from a translation without the Retro-WFC payload now refuses to package instead of crashing on entering WFC, and on Android ImGui keeps off SDL's cursor, which ART aborts on from a guest fiber's stack. Co-Authored-By: Claude Opus 5 --- android/QuestGameKit.psm1 | 13 +++ .../wiicompiled/quest/launcher/GameBuild.kt | 86 +++++++++++++++++-- docs/quest-port.md | 13 ++- runtime/src/settings_overlay.cpp | 12 +++ .../RuntimeNativeGuestEffectAnalyzer.cs | 4 + .../src/Translator.Core/GeneratedMarkers.cs | 17 ++-- .../RuntimeNativeIndexTests.cs | 43 ++++++++++ 7 files changed, 175 insertions(+), 13 deletions(-) diff --git a/android/QuestGameKit.psm1 b/android/QuestGameKit.psm1 index ff422a9..0da09f2 100644 --- a/android/QuestGameKit.psm1 +++ b/android/QuestGameKit.psm1 @@ -497,6 +497,19 @@ function Invoke-QuestGameBuild { $sources[$slot] = @($slotSources | ForEach-Object { if ($_.EndsWith('.S')) { & $toElf $_ } else { $_ } }) } if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" } + # Online play needs the Retro-WFC payload translated into the mod (translate-mod + # --retro-wfc-payload). Without it the mod downloads the payload at run time and jumps into + # code that was never translated: the game crashes on entering Retro Rewind WFC. + if ($Product -eq 'retro_rewind') { + $dataPatches = @($sources.Values | ForEach-Object { $_ } | + Where-Object { [IO.Path]::GetFileName($_) -eq 'mod_data_patches.cpp' }) + if ($dataPatches.Count -ne 1 -or + -not (Select-String -LiteralPath $dataPatches[0] -Pattern 'kRetroWfcInitializerAddress' -SimpleMatch -Quiet)) { + throw ('This Retro Rewind translation has no Retro-WFC payload, so online play would crash. ' + + 'Translate the mod again with its payload (translate-mod --retro-wfc-payload, or repair ' + + 'Retro Rewind in WiiCompiled with the payload download on), then build again.') + } + } foreach ($slot in $sources.Keys) { if ($sources[$slot].Count -eq 0) { throw "The $slot sources of a $Product game are missing from $shards" } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt index 0e1e2e2..dafd2b4 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt @@ -35,7 +35,8 @@ import org.wiicompiled.quest.GameStorage * game kit (assets/game_kit) into private storage; * 2. download the Android NDK files the build needs from Google, checked against the pins the * toolchain carries (ndk.json); - * 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards; + * 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards + * (Retro Rewind adds translate-mod, with the Retro-WFC payload downloaded from rwfc.net); * 4. compile the generated sources with the kit's flags, several at a time; * 5. link them with the kit's objects and archives (kit.json link.lld); * 6. install libmain.so and its game.json like an imported game. @@ -65,6 +66,12 @@ object GameBuild { private const val TRANSLATOR_THREADS = 4 private const val EXPECTED_TRANSLATION_SECONDS = 600.0 private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024 + // WiiCompiled Setup's fixed endpoint, size cap and staging layout + // (Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs), which validate-retro-wfc-payload expects. + private const val RETRO_WFC_PAYLOAD_URL = "https://rwfc.net/api/wfc/payload?g=RMCPD00" + private const val RETRO_WFC_PAYLOAD_MAX_BYTES = 16 * 1024 * 1024 + private const val RETRO_WFC_DIRECTORY = "retro-wfc" + private const val RETRO_WFC_PAYLOAD_FILE = "binary/payload.RMCPD00.bin" /** 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? { @@ -276,13 +283,82 @@ object GameBuild { } } + /** + * The Retro-WFC payload Retro Rewind's online play runs. translate-mod lowers it into the + * mod; without it the mod downloads the payload while connecting and jumps into code that + * was never translated. Retried once, like Setup's download. + */ + fun downloadRetroWfcPayload(): File { + val file = File(workspace, "$RETRO_WFC_DIRECTORY/$RETRO_WFC_PAYLOAD_FILE") + file.parentFile?.mkdirs() + log.line("Downloading the Retro-WFC payload from $RETRO_WFC_PAYLOAD_URL") + var failure: IOException? = null + for (attempt in 1..2) { + if (cancelled()) throw InterruptedIOException("Build cancelled") + try { + file.writeBytes(fetchRetroWfcPayload()) + log.line("Retro-WFC payload: ${file.length()} bytes, sha256 ${BuildRecipe.hex(sha256(file))}") + return file + } catch (e: IOException) { + // A socket timeout is an InterruptedIOException too, which run() takes for a cancel. + failure = e + log.line("Retro-WFC payload download attempt $attempt failed: $e") + if (attempt == 1) Thread.sleep(1_000) + } + } + if (cancelled()) throw InterruptedIOException("Build cancelled") + throw IOException( + "Retro Rewind's online play needs the Retro-WFC payload from rwfc.net, which could not be " + + "downloaded (${failure?.message}). Check the headset's internet connection, then build again.", + ) + } + + fun fetchRetroWfcPayload(): ByteArray { + val connection = URL(RETRO_WFC_PAYLOAD_URL).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 30_000 + connection.readTimeout = 30_000 + // A redirect would fetch from a target other than the fixed endpoint; Setup refuses it too. + connection.instanceFollowRedirects = false + connection.setRequestProperty("Accept-Encoding", "identity") + val code = connection.responseCode + if (code != HttpURLConnection.HTTP_OK) throw IOException("rwfc.net answered $code") + connection.inputStream.use { input -> + val bytes = java.io.ByteArrayOutputStream() + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + bytes.write(buffer, 0, read) + if (bytes.size() > RETRO_WFC_PAYLOAD_MAX_BYTES) throw IOException("the payload is unexpectedly large") + } + return bytes.toByteArray() + } + } finally { + connection.disconnect() + } + } + /** Translates the disc unless this workspace already holds a translation of the same inputs. */ fun translate(tools: ToolProcess, identity: String): String? { val generated = File(workspace, "generated") val provenance = File(generated, "translation-provenance.txt") + // Fetched before anything else, so an unreachable server fails the build before the + // long base translation rather than after it. + val payload = if (profile.modPack) downloadRetroWfcPayload() else null + if (payload != null) { + translator( + tools, "checking the Retro-WFC payload", + "validate-retro-wfc-payload", "--directory", File(workspace, RETRO_WFC_DIRECTORY).absolutePath, + )?.let { return it } + } // 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 "" + // identity (and the payload's) is part of what the stored translation is reused for. + val modIdentity = if (payload != null) { + "${BuildRecipe.hex(sha256(GameStorage.modCodePul(context)))} ${BuildRecipe.hex(sha256(payload))}" + } 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) { @@ -324,7 +400,7 @@ object GameBuild { }?.let { return it } // 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. + // Launcher/LocalBuild.ps1 does on a PC, with the Retro-WFC payload for online play. val modOutput = "build/mods/retro_rewind_full_cpp" if (profile.modPack) { report(350, Step.Translate, 2, steps) @@ -342,7 +418,7 @@ object GameBuild { "--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", + "--prefer-cached-inputs", "--emit-cpp", "--retro-wfc-payload", payload!!.absolutePath, "--threads", TRANSLATOR_THREADS.toString(), )?.let { return it } } diff --git a/docs/quest-port.md b/docs/quest-port.md index 3b2bc95..591e0ef 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -297,6 +297,17 @@ Home's main button becomes **Download Retro Rewind** whenever that game is selec missing, and Settings → About shows the installed version with an Update button. Building the mod on the headset needs the mod's `Code.pul`, which is part of the pack, so the same rule covers it. +Online play (Retro Rewind WFC) needs the Retro-WFC payload translated into the mod, as on a PC: +`translate-mod --retro-wfc-payload`, with the payload Setup downloads and verifies from +`https://rwfc.net/api/wfc/payload?g=RMCPD00`. Without it the mod downloads `WWFC/Payload` while +connecting and jumps into code that was never translated, and the game stops with a missing +translated function (seen: `0x81895BF4`, called from `rr_kamek_*` on the `NHTTPi_CommThreadProc` +thread, with `r3` pointing at `"WWFC/Payload"`). So the headset +build downloads the payload before translating and checks it with `validate-retro-wfc-payload`, and +`Invoke-QuestGameBuild` refuses a Retro Rewind translation whose `mod_data_patches.cpp` has no +`kRetroWfcInitializerAddress`. The payload is fixed at build time: when rwfc.net publishes a new +one, rebuild the game. + ### Game packages (.wcgame) and Import from computer A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted @@ -465,7 +476,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-QuestGame.ps1 -Product retro_rewind -Mod -Install # the mod and its pack (needs translate-mod output) +powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod -Install # the mod and its pack (needs translate-mod output with --retro-wfc-payload) adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher ``` diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 5040a8f..3c9b4d4 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -1875,6 +1875,12 @@ bool IsMouseActivity(const SDL_Event& event) { // Runs on the thread that pumps SDL events (the same one that calls Draw), so // the SDL cursor calls are safe here. void UpdateCursorAutoHide() { +#if defined(__ANDROID__) + // No pointer icon to manage on a headset, and SDL changes it through Java, + // which ART aborts on from a guest fiber's stack: Draw runs on whichever + // guest thread advances the retrace. InitializeRuntimeSettings keeps + // ImGui's SDL backend off the cursor for the same reason. +#else const bool shouldHide = !g_topBarVisible && Clock::now() - g_lastMouseActivity >= kCursorAutoHideDelay; if (shouldHide == g_cursorHidden) { @@ -1889,6 +1895,7 @@ void UpdateCursorAutoHide() { ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; SDL_ShowCursor(); } +#endif } // Alt+Enter toggles the display mode inside aurora without going through the @@ -2045,6 +2052,11 @@ void DrawVrSettingsPanel() { } // namespace void InitializeRuntimeSettings() noexcept { +#if defined(__ANDROID__) + // ImGui_ImplSDL3_NewFrame would otherwise call SDL_SetCursor/SDL_HideCursor + // (Java on Android) from the guest fiber that starts the next host frame. + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; +#endif PAD_HLE_SetRumbleEnabled(g_rumbleEnabled); InputBindings::Reload(); controller_mapping_wizard::LoadPersistedMappings(); diff --git a/translator/src/Translator.Core/Analysis/RuntimeNativeGuestEffectAnalyzer.cs b/translator/src/Translator.Core/Analysis/RuntimeNativeGuestEffectAnalyzer.cs index 5bc5f92..20b39f7 100644 --- a/translator/src/Translator.Core/Analysis/RuntimeNativeGuestEffectAnalyzer.cs +++ b/translator/src/Translator.Core/Analysis/RuntimeNativeGuestEffectAnalyzer.cs @@ -42,6 +42,10 @@ public static class RuntimeNativeGuestEffectAnalyzer var address = ParseAddress(match.Groups["address"].Value); stubAddresses.Add(address); var symbol = match.Groups["symbol"].Value; + // GX_DEFERRED_OVERRIDE_VOID generates `symbol` itself, which only posts the + // hand-written `symbol_gx` to the GX thread with the same arguments. + if (match.Groups["deferred"].Success) + symbol += "_gx"; var tail = match.Groups["tail"].Value; var isVoid = match.Groups["void"].Success; if (TryParseStubTail(tail, isVoid, out var returnType, out var arguments) && diff --git a/translator/src/Translator.Core/GeneratedMarkers.cs b/translator/src/Translator.Core/GeneratedMarkers.cs index 6ce362d..154a000 100644 --- a/translator/src/Translator.Core/GeneratedMarkers.cs +++ b/translator/src/Translator.Core/GeneratedMarkers.cs @@ -94,24 +94,27 @@ public static partial class GeneratedMarkers /// /// The registration half of PPC_NATIVE_OVERRIDE: address and symbol /// only. Deliberately a prefix match, so an override whose argument list - /// contains a semicolon still registers. + /// contains a semicolon still registers. GX_DEFERRED_OVERRIDE_VOID + /// (runtime/src/hle/gx/gx_internal.h) takes the same arguments and expands + /// to PPC_NATIVE_OVERRIDE_VOID, which a source scan cannot see. /// - [GeneratedRegex(@"PPC_NATIVE_OVERRIDE(?:_VOID)?\s*\(\s*(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)] + [GeneratedRegex(@"(?:PPC_NATIVE|GX_DEFERRED)_OVERRIDE(?:_VOID)?\s*\(\s*(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)] public static partial Regex NativeOverridePattern(); /// - /// The same macro read for its signature rather than its registration: the - /// effect analyzer needs the argument list, which only the full - /// statement-terminated form can capture. + /// The same macros read for their signature rather than their registration: + /// the effect analyzer needs the argument list, which only the full + /// statement-terminated form can capture. deferred marks the GX form, + /// whose hand-written body is symbol_gx. /// - [GeneratedRegex(@"PPC_NATIVE_OVERRIDE(?_VOID)?\s*\(\s*(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)\s*,?\s*(?[^;]*?)\);", RegexOptions.CultureInvariant | RegexOptions.Singleline)] + [GeneratedRegex(@"(?:PPC_NATIVE|(?GX_DEFERRED))_OVERRIDE(?_VOID)?\s*\(\s*(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)\s*,?\s*(?[^;]*?)\);", RegexOptions.CultureInvariant | RegexOptions.Singleline)] public static partial Regex NativeOverrideSignaturePattern(); /// /// The void-stub form read for its declared host parameter list, which is /// what the void-stub ABI provider infers argument registers from. /// - [GeneratedRegex(@"PPC_NATIVE_OVERRIDE_VOID\s*\(\s*(?[0-9A-Fa-f]+)\s*,\s*[^,]+,\s*\((?[^)]*)\)\s*,", RegexOptions.CultureInvariant | RegexOptions.Singleline)] + [GeneratedRegex(@"(?:PPC_NATIVE|GX_DEFERRED)_OVERRIDE_VOID\s*\(\s*(?[0-9A-Fa-f]+)\s*,\s*[^,]+,\s*\((?[^)]*)\)\s*,", RegexOptions.CultureInvariant | RegexOptions.Singleline)] public static partial Regex NativeOverrideVoidArgumentsPattern(); [GeneratedRegex(@"GX_FATAL_STUB\s*\(\s*(?
[0-9A-Fa-f]{8})", RegexOptions.CultureInvariant)] diff --git a/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs b/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs index e630654..d30a419 100644 --- a/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs +++ b/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs @@ -80,4 +80,47 @@ public sealed class RuntimeNativeIndexTests Directory.Delete(directory, recursive: true); } } + + [Fact] + public void BuildReadsGxDeferredOverridesLikeTheStubsTheyExpandTo() + { + // runtime/src/hle/gx: the macro defines Deferred itself and registers it through + // PPC_NATIVE_OVERRIDE_VOID; missing it put a translated copy of the function beside + // the native one (duplicate symbols at link time). + var directory = Path.Combine(Path.GetTempPath(), $"mkw-native-deferred-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "gx_internal.h"), """ + #define GX_DEFERRED_OVERRIDE_VOID(addr_hex, name, arg_list, call_list) \ + extern "C" void name arg_list { GxThread::Post(&name##_gx GX_COMMA_ARGS call_list); } \ + PPC_NATIVE_OVERRIDE_VOID(addr_hex, name, arg_list, call_list) + """); + File.WriteAllText(Path.Combine(directory, "gx_pixel.cpp"), """ + static void Deferred_gx(uint32_t mode, float value) { GXSetDither(mode); (void)value; } + GX_DEFERRED_OVERRIDE_VOID(80172930, Deferred, (uint32_t mode, float value), (mode, value)); + """); + + var index = RuntimeNativeIndexBuilder.Build(directory); + var registration = Assert.Single(index.Registrations); + Assert.Equal(0x80172930u, registration.Address); + Assert.Equal("Deferred", registration.Symbol); + Assert.True(registration.ExcludesBaseTranslation); + + var abi = Assert.Single(index.VoidStubAbis); + Assert.Equal(["f1", "r3"], abi.ArgumentRegisters); + Assert.Equal(["f1"], abi.ScalarFloatArgumentRegisters); + + // Analyzed from the hand-written Deferred_gx body, as the stub was before it moved. + var effect = Assert.Single(index.Effects); + Assert.True(effect.IsPrecise); + Assert.Equal(1u << 3, effect.Contract.GprReadBeforeWriteMask); + Assert.Equal(1u << 1, effect.Contract.FprReadBeforeWriteMask); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } }