diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index dd5e2e9..72ed37f 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -17,7 +17,7 @@ Build from the repository root on Windows: ```powershell ./Launcher/Build-Installer.ps1 -OutputDirectory Launcher/dist-vr -./Launcher/Verify-Release.ps1 -Tag v0.2.41 -SetupPath (Resolve-Path Launcher/dist-vr/WiiCompiled-Setup.exe) +./Launcher/Verify-Release.ps1 -Tag v0.2.43 -SetupPath (Resolve-Path Launcher/dist-vr/WiiCompiled-Setup.exe) ./Launcher/dist-vr/WiiCompiled-Setup.exe --self-test ./Launcher/Test-Recompilation.ps1 -StageDirectory build/vr-synthetic-validation dotnet test translator/Translator.sln -c Release diff --git a/Launcher/Build-DawnVulkan.ps1 b/Launcher/Build-DawnVulkan.ps1 new file mode 100644 index 0000000..6abca55 --- /dev/null +++ b/Launcher/Build-DawnVulkan.ps1 @@ -0,0 +1,68 @@ +# Builds the pinned Dawn DLL with Aurora's native Windows Vulkan/OpenXR bridge. +# Run on a maintainer machine with VS 2022 C++ tools, CMake, Git and Python 3. +[CmdletBinding()] +param( + [string]$WorkDirectory = (Join-Path $PSScriptRoot 'artifacts\dawn-vulkan-build'), + [string]$Destination = (Join-Path $PSScriptRoot 'artifacts\dawn-vulkan'), + # Source of the DirectX shader compiler DLLs, which Dawn's install does not produce. + [string]$StockDawnDirectory = (Join-Path $PSScriptRoot 'artifacts\dependencies\dawn_prebuilt'), + [string]$Python = 'python', + [int]$Jobs = 8 +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +$revision = '13abc3bc8ea2d3c2050f9e77a12d012108ceee24' +$archiveHash = '713bea5b92d4f6c5175752fd7cbf1c3c5ce36598ff5dd98685d8a1216614ebba' +$WorkDirectory = [IO.Path]::GetFullPath($WorkDirectory) +$Destination = [IO.Path]::GetFullPath($Destination) +[IO.Directory]::CreateDirectory($WorkDirectory) | Out-Null +$archive = Join-Path $WorkDirectory 'dawn-source.tar.gz' +if (-not (Test-Path -LiteralPath $archive)) { + Invoke-WebRequest "https://github.com/google/dawn/archive/$revision.tar.gz" -OutFile $archive -UseBasicParsing +} +if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $archiveHash) { + throw 'Dawn source archive does not match the pinned SHA-256.' +} +$source = Join-Path $WorkDirectory "dawn-$revision" +if (-not (Test-Path -LiteralPath $source)) { + # Single quotes inside: Windows PowerShell drops the inner double quotes when it builds a + # native command line, and Python then reads filter=data as a name. + & $Python -c "import sys,tarfile; tarfile.open(sys.argv[1]).extractall(sys.argv[2], filter='data')" $archive $WorkDirectory + if ($LASTEXITCODE -ne 0) { throw 'Dawn source extraction failed (Python 3.12+ required).' } +} +$patch = Join-Path $PSScriptRoot '..\aurora-main\patches\dawn\apply.py' +& $Python $patch $source +if ($LASTEXITCODE -ne 0) { throw 'Applying the Aurora Dawn bridge failed.' } +$build = Join-Path $WorkDirectory 'build' +& cmake -S $source -B $build -G 'Visual Studio 17 2022' -A x64 ` + "-DPython3_EXECUTABLE=$Python" -DDAWN_FETCH_DEPENDENCIES=ON ` + -DDAWN_BUILD_MONOLITHIC_LIBRARY=SHARED -DDAWN_ENABLE_INSTALL=ON ` + -DDAWN_BUILD_SAMPLES=OFF -DDAWN_BUILD_TESTS=OFF -DDAWN_BUILD_BENCHMARKS=OFF ` + -DTINT_BUILD_TESTS=OFF -DTINT_BUILD_CMD_TOOLS=OFF -DDAWN_USE_GLFW=OFF ` + -DDAWN_ENABLE_D3D11=OFF -DDAWN_ENABLE_D3D12=ON -DDAWN_ENABLE_VULKAN=ON ` + -DDAWN_ENABLE_DESKTOP_GL=OFF -DDAWN_ENABLE_OPENGLES=OFF "-DCMAKE_INSTALL_PREFIX=$Destination" +if ($LASTEXITCODE -ne 0) { throw 'Dawn configuration failed.' } +& cmake --build $build --config Release --parallel $Jobs +if ($LASTEXITCODE -ne 0) { throw 'Dawn build failed.' } +& cmake --install $build --config Release +if ($LASTEXITCODE -ne 0) { throw 'Dawn installation failed.' } +$dll = Join-Path $Destination 'bin\webgpu_dawn.dll' +if (-not (Test-Path -LiteralPath $dll)) { throw "Dawn DLL missing: $dll" } +# Dawn's own install stages only its DLL, but this package replaces dawn_prebuilt wholesale and +# LocalBuild.ps1 copies the DirectX shader compiler out of it into the product. Dawn loads those +# two at run time rather than importing them, so leaving them out breaks D3D12 shader compilation +# long after installation instead of failing here. +foreach ($name in @('dxcompiler.dll', 'dxil.dll')) { + if (Test-Path -LiteralPath (Join-Path $Destination "bin\$name")) { continue } + $stock = Join-Path $StockDawnDirectory "bin\$name" + if (-not (Test-Path -LiteralPath $stock)) { + throw "The pinned Dawn package has no $name; point -StockDawnDirectory at the prepared dawn_prebuilt." + } + Copy-Item -LiteralPath $stock -Destination (Join-Path $Destination 'bin') +} +[ordered]@{ + SourceRevision = $revision + AuroraVulkanAbi = 1 + DllSha256 = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Destination 'aurora-vulkan.json') -Encoding UTF8 +Write-Host "Custom Dawn package ready: $Destination" diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index b1d5b90..54ca774 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -99,7 +99,7 @@ Assert-File (Join-Path $portableTools 'Ninja\ninja.exe') 'Portable Ninja' # to compile (launcher/Prepare-NativePrebuilt.ps1). # Kept in step with InstalledLayout.DependencyNames by Test-PinnedFacts.ps1: the installed host # refuses to call a toolkit complete unless every one of these directories is present. -$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','xxhash','zlib','zstd') +$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','vulkan_headers','xxhash','zlib','zstd') $missingSources = @($requiredDependencies | Where-Object { $_ -ne 'native_prebuilt' } | Where-Object { -not (Test-Path -LiteralPath (Join-Path $dependencySources $_) -PathType Container) }) if ($missingSources.Count -gt 0) { diff --git a/Launcher/Directory.Build.props b/Launcher/Directory.Build.props index e501a5f..b18f7eb 100644 --- a/Launcher/Directory.Build.props +++ b/Launcher/Directory.Build.props @@ -1,5 +1,5 @@ - 0.2.41 + 0.2.43 diff --git a/Launcher/Prepare-Dependencies.ps1 b/Launcher/Prepare-Dependencies.ps1 index 9777ebf..a00a5c4 100644 --- a/Launcher/Prepare-Dependencies.ps1 +++ b/Launcher/Prepare-Dependencies.ps1 @@ -9,7 +9,9 @@ param( [string]$Destination, # Windows metadata source for cppwinrt.exe: 'local' (this machine's WinMetadata), 'sdk', or an # installed SDK version such as 10.0.26100.0. - [string]$CppWinRtInput = 'local' + [string]$CppWinRtInput = 'local', + # Output of Build-DawnVulkan.ps1. Rebuild native_prebuilt after changing Dawn. + [string]$DawnVulkanPackage ) $ErrorActionPreference = 'Stop' @@ -27,6 +29,11 @@ $runtimeCMake = Join-Path $repoRoot 'runtime\CMakeLists.txt' # FETCHCONTENT_SOURCE_DIR_ (NativeBuildFlags.ps1), so the names are the declared # FetchContent names, not the upstream project names. $packages = @( + [pscustomobject]@{ + Name = 'vulkan_headers'; File = 'vulkan-headers-015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz' + Uris = @('https://github.com/KhronosGroup/Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz') + Pins = @(@{ File = $runtimeCMake; Text = 'Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz' }) + }, [pscustomobject]@{ Name = 'SDL'; File = 'SDL3-3.4.4.tar.gz' Uris = @('https://github.com/libsdl-org/SDL/releases/download/release-3.4.4/SDL3-3.4.4.tar.gz') @@ -175,6 +182,22 @@ function Expand-Package([string]$Archive, [string]$Target) { foreach ($package in $packages) { Assert-Pinned $package $target = Join-Path $Destination $package.Name + if ($package.Name -eq 'dawn_prebuilt' -and $DawnVulkanPackage) { + $custom = [IO.Path]::GetFullPath($DawnVulkanPackage) + $manifest = Get-Content -LiteralPath (Join-Path $custom 'aurora-vulkan.json') -Raw | ConvertFrom-Json + $dll = Join-Path $custom 'bin\webgpu_dawn.dll' + if ($manifest.SourceRevision -ne '13abc3bc8ea2d3c2050f9e77a12d012108ceee24' -or + $manifest.AuroraVulkanAbi -ne 1 -or + (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() -ne $manifest.DllSha256) { + throw 'Custom Dawn package provenance or DLL hash does not match.' + } + if (Test-Path -LiteralPath $target) { + throw "Use a fresh dependency destination for custom Dawn: $target already exists." + } + Copy-Item -LiteralPath $custom -Destination $target -Recurse + Write-Host 'Prepared custom Dawn with Windows Vulkan OpenXR support' + continue + } if (Test-Path -LiteralPath $target -PathType Container) { continue } Expand-Package (Get-Archive $package) $target Write-Host "Prepared $($package.Name)" diff --git a/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs b/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs index 03521d6..bf562e2 100644 --- a/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs @@ -27,7 +27,7 @@ internal static class InstalledLayout public static readonly string[] DependencyNames = [ "abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "libusb", "native_prebuilt", - "openxr", "png", "SDL", "sqlite3", "tracy", "xxhash", "zlib", "zstd" + "openxr", "png", "SDL", "sqlite3", "tracy", "vulkan_headers", "xxhash", "zlib", "zstd" ]; } diff --git a/OPENXR.md b/OPENXR.md index 2d243a2..c148b90 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -3,7 +3,10 @@ WiiCompiled has an opt-in OpenXR rendering path. The first functional backend is Windows D3D12. It asks the OpenXR runtime for the required GPU before Aurora creates Dawn, then copies each eye on that same D3D12 device and queue into the acquired OpenXR swapchain images. Eye submission -stays on the GPU; there is no CPU texture readback and no second graphics device. +stays on the GPU; there is no CPU texture readback and no second graphics device. Windows Vulkan is +an opt-in second binding built on the same design: the OpenXR runtime creates Dawn's Vulkan instance +and device (`XR_KHR_vulkan_enable2`) and eyes are copied on that same queue. It needs a custom Dawn +build; see [Windows Vulkan](#windows-vulkan). This is an experimental renderer, not yet a release-ready VR mode. @@ -11,14 +14,17 @@ This is an experimental renderer, not yet a release-ready VR mode. - A Windows OpenXR runtime selected as the system's active runtime. - A connected headset supported by that runtime. -- A D3D12-capable GPU and driver accepted by both OpenXR and Dawn. +- A D3D12-capable GPU and driver accepted by both OpenXR and Dawn, or for the opt-in Vulkan + binding a Vulkan 1.1+ driver plus the custom Dawn described under [Windows Vulkan](#windows-vulkan). - A build made with `MKW_ENABLE_OPENXR=ON`, which defaults on for Windows and off elsewhere while the Vulkan bridge remains capability-gated. For managed installation, use [WheelWizard VR](https://github.com/iChris4/WheelWizard_VR/releases/latest) and enable **Settings → Other → WiiCompiled (beta) → Enable WiiCompiled OpenXR VR (beta)**. -The launcher sets `vr.enabled=true`, `vr.required=false`, and `video.graphics_api="d3d12"` before -each VR launch, preserving other preferences. Its portable configuration lives at +The launcher sets `vr.enabled=true` and `vr.required=false` before each VR launch, preserving other +preferences. Its **Graphics API** row picks the binding, DirectX 12 or Vulkan, and keeps that choice; +any other value is repaired to `d3d12` at launch, because OpenXR refuses the rest. Its portable +configuration lives at `RecompVR/UserData/Config.toml` beneath WheelWizard's data folder. Normal graphics settings remain in `Recomp/UserData/Config.toml`. Both backends use the normal installation's effective NAND. @@ -39,19 +45,29 @@ world_units_per_meter = 500.0 hud_distance_meters = 2.0 hud_width_meters = 2.4 hud_virtual_screen = true +flat_screen = false stop_at_display_copy = true skip_copy_clears = true first_person = false -first_person_units_per_meter = 30.0 -first_person_head_up_meters = 3.0 +first_person_toggle_click = true +first_person_seat = "cockpit" +cockpit_units_per_meter = 100.0 +first_person_units_per_meter = 50.0 +first_person_head_up_meters = 1.5 first_person_head_forward_meters = 0.0 first_person_head_right_meters = 0.0 first_person_hide_driver = true first_person_hidden_model = 0 -first_person_rotation = "yaw" +first_person_rotation = "yaw_pitch" +steering_wheel = true +native_steering_wheel = true +hand_steering = true performance_level = "boost" ``` +The seven `wheel_*` hand-steering tuning keys are described in +[Steering wheel and hand steering](#steering-wheel-and-hand-steering). + To play this installation on the desktop instead, set `enabled = false`, close the game completely, and start it again. These settings are read only at launch. The in-game F10 settings bar also exposes the enable switch, but a restart is still required. @@ -124,6 +140,24 @@ and 0.8 on the Quest, whose mobile GPU needs the headroom. launch and govern both the menu screen and the in-race 2D screen, so 2D content keeps its place across the transition. `hud_virtual_screen` decides whether the race's 2D layer uses that screen; it is live and can be flipped from the F10 settings bar. +`flat_screen` (default off) keeps races on that same flat screen, as the menus are, instead of +immersive stereo: the whole race, 3D world and HUD alike, is the game's own picture on the quad, as +in DolphinXR's Flat Screen mode. The first-person camera, hand steering, the lean-back angle, VR +frame interpolation and `hud_virtual_screen` shape only the immersive race view, so none of them +apply while it is on; the right-thumbstick first-person toggle is ignored rather than changing the saved +setting. It is live, as **F10 → VR → Flat Screen mode** (the headset panel's VR tab) and the Quest +launcher's Settings page, and turning it on or off mid-race switches on the next frame through the +presentation policy's safety generation. +`passthrough` (Quest only, default on) shows the room through the headset's cameras around the +menu screen and every other virtual screen, instead of black: an `XR_FB_passthrough` +reconstruction layer submitted under the screen's quad, as PPSSPP VR does, with the blend mode +left `OPAQUE`. An immersive race never shows it, and the cameras are paused for the race; a race in +`flat_screen` is a virtual screen like the menus, so the room shows around it too. It is +live, from the headset panel's VR tab or the launcher's Settings page. The app declares +`com.oculus.feature.PASSTHROUGH`, without which Horizon OS composites nothing for that layer. +So that the room frames the picture rather than black bands, the Quest's menu quad shows only the +part of its eye-sized image Aurora draws into (the desktop snapshot, and the in-eye settings +panel's rectangle), at the same size per pixel, so nothing moves. `stop_at_display_copy` ends eye replay at the final `GXCopyDisp`, matching the frame shown on the desktop. `skip_copy_clears` independently suppresses the EFB reset performed after a copy. Both default on and can be changed live from the F10 settings bar for diagnostics. @@ -143,6 +177,17 @@ thread (`runtime/src/vr/openxr_input.cpp`), which feeds a virtual SDL gamepad th to a port like any other. `controller_mode` decides what the game finds on that port, and is live from **F10 > VR > VR controllers**; the game sees a change as a controller reconnection. +The pacing thread only publishes that gamepad; the game thread writes it to SDL where it already +polls controllers (`OpenXRApplyControllerState`, called from `PAD__Read_HLE` and the overlay's +per-frame work). SDL holds its joystick lock for the length of a device enumeration, and the +Bluetooth Wii Remote rescan (**F10 > Controller settings > Keep scanning**, `wii_continuous_scan`, +off by default) makes SDL close and reopen every HID device twice per scan. Measured at 15 ms on a +plain desk and over 200 ms with a Lighthouse setup's dongles on the bus, which is why the pacing +thread must not wait on it: a frame it holds open that long costs the compositor every display slot +that passes, and `[xr-diag]` reports it as a stalled, late frame with skipped display slots. That +rescan still pauses the *game* thread for as long, so leave it off unless a real Wii Remote is in +use. + `"wii_remote"`, the default, presents them as a Wii Remote with a Nunchuk, the way DolphinXR's OpenXR Wii Remote does, with buttons adapted from its default `OpenXR Wii Remote` profile for the Touch controllers. The port is served through KPAD like a Bluetooth remote @@ -152,19 +197,23 @@ Touch controllers. The port is served through KPAD like a Bluetooth remote | --- | --- | | Right A | A | | Right trigger | B | +| Right B | C (look behind) | | Right stick up / down | 1 / 2 | | Left X | − | | Left menu | + | | Left stick | Nunchuk stick | | Left trigger | Z | -| Left grip | C | | Left Y | Settings panel (not a Wii button) | +| Either grip | Takes hold of the wheel (not a Wii button) | +| Right stick click | First-person camera on / off (not a Wii button) | | Right controller motion and aim | Wii Remote accelerometer and pointer | | Left controller motion | Nunchuk accelerometer | -Analog inputs count as pressed past half travel. Right B, right stick left / right and the stick -clicks are unbound, and no controller button presses HOME. The game's Wii Remote rumble vibrates -both controllers, subject to the ordinary controller-vibration switch. +Analog inputs count as pressed past half travel. The grips, right stick left / right and the left +stick click press no Wii button, and nothing presses HOME. C sits on right B rather than a grip +because hand steering holds a grip down for a whole corner, and C is the game's look-behind. The +game's Wii Remote rumble vibrates both controllers, subject to the ordinary controller-vibration +switch. **Motion.** Each XR frame the aim and grip poses are located at the measured current time (`XR_KHR_win32_convert_performance_counter_time`, `XR_KHR_convert_timespec_time` on Android), not @@ -181,7 +230,7 @@ with the screen the renderer is showing, and the point it meets is where the cur is nothing to recenter. On the menu screen that is the quad layer, `hud_width_meters` across with the eye texture's aspect, and the pointer spans the game picture inside it (Aurora letterboxes the desktop image into the quad and the picture into the desktop image, so a 4:3 picture keeps its -pillarboxes). During a race it is the 2D layer's screen, `hud_distance_meters` ahead of the latched +pillarboxes; the Quest crops the quad to the desktop image without changing where it is). During a race it is the 2D layer's screen, `hud_distance_meters` ahead of the latched race origin and turned by the lean-back angle, with the picture's aspect. With `hud_virtual_screen = false` the race's 2D layer has no fixed place and the pointer is off. The game's own pointer switch (`KPADEnableDpd` / `KPADDisableDpd`) is honoured as well. @@ -197,11 +246,17 @@ Raw IR camera dots in `KPADGetUnifiedWpadStatus` stay invalid; the game reads th **Settings in the headset.** Left Y opens the settings panel described below; while it is open the controllers operate the panel and the game sees them idle. +**Hand steering.** With `hand_steering` on, in the first-person cockpit, a grip squeezed near the +steering wheel takes hold of it, and while held the wheel steers through the Nunchuk stick's X axis; +see [Steering wheel and hand +steering](#steering-wheel-and-hand-steering). Turning the wheel moves the controllers, and the game's +own motion detection still reads them, so a sharp enough turn can read as a shake. + `"gamepad"` keeps the controllers one ordinary gamepad read through PAD as a GameCube controller: A/B → South/East, X/Y → West/North, index triggers → trigger axes, grips → shoulders, thumbsticks → sticks (clicks → stick buttons), left menu → Start. Every binding in the F10 controller menu applies. Left Y is GameCube Y here, so clicking both thumbsticks together opens the settings panel -instead. +instead. The right thumbstick click on its own still toggles the first-person camera. Bindings are suggested for `oculus/touch_controller` (Quest 2, 3 and Pro) and `khr/simple_controller`. `mkw_vr_wii_remote_tests` checks the accelerometer frame, the pointer @@ -236,24 +291,75 @@ of you. How it is drawn: `settings_overlay.cpp` builds the panel with a second Dear ImGui context of its own, a 1440 × 1080 canvas at twice the desktop menu's scale with its own font atlas, fed by the pointer that `openxr_input.cpp` publishes through `vr/openxr_settings_panel.h`. Aurora renders that draw data -into a panel texture once per sealed frame and lays it over each eye after the eye is finished -(`aurora-main/lib/stereo_overlay.cpp`): through the eye's frustum and `viewFromCenter` onto the -screen rectangle for an immersive eye (including headset-rate interpolated eyes, which reuse the -texture), and as a centred rectangle on a virtual-screen eye image. The eye images the OpenXR -backends already submit carry it, so no extra swapchain or composition layer is involved. The -ImGui backend keeps a single projection uniform, so the panel's pass is submitted on its own command -buffer before the desktop's ImGui pass of the same frame is recorded. +into a panel texture once per sealed frame (`aurora-main/lib/stereo_overlay.cpp`). The ImGui backend +keeps a single projection uniform, so the panel's pass is submitted on its own command buffer before +the desktop's ImGui pass of the same frame is recorded. + +The panel is shown as a compositor quad layer of its own, submitted over the scene's projection or +menu quad layer. The compositor samples the 1440 × 1080 canvas directly, so its text stays sharp +whatever `render_scale` gives the eyes. Every backend (D3D12, Windows Vulkan, Quest) makes the +panel's swapchain pair the first time the panel opens (two 1440 × 1080 swapchains, plus two shared +buffers on the Quest) and keeps it for the session. Until then nothing is allocated, and while the +panel is closed nothing is copied or submitted. While it is open, each frame hands Aurora one more +target after the eyes: the stereo bridge copies the panel texture into it with the eyes (or a +transparent image on a frame where the panel is not drawn). The layer follows the eyes' swapchain +pairing: the image a frame wrote is shown only once that frame is submitted, so a cancelled frame +never shows an unwritten panel. The quad hangs exactly where the pointer's hits are tested +(`SettingsPanelScreen` in `openxr_integration.cpp`). ImGui's premultiplied output is blended with +`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT`, and Aurora leaves the panel out of the eyes +(`aurora_set_stereo_panel_layer`). + +If a backend cannot make the panel's swapchains, it logs that once and the panel is drawn into the +eye images instead: through the eye's frustum and `viewFromCenter` onto the screen rectangle for an +immersive eye (including headset-rate interpolated eyes, which reuse the texture), and as a centred +rectangle on a virtual-screen eye image. On the Quest, `adb shell setprop +debug.wiicompiled.panel_layer 0` switches to that path at run time, to compare the two. + +Measured on a Quest 3 (base game, a Grand Prix start with the player idle, `render_scale = 0.8`, +60 FPS, eight interleaved rounds per state), the layer costs nothing while the panel is closed. While +it is open, the app's GPU time is 10.5 ms per frame with the layer, against 9.7 ms drawn into the eyes +(9.4 ms closed). GPU load is 74% against 67%, and the compositor's time 1.05 ms against 0.75 ms. Game +and headset frame rates did not change. The compositor redraws the layer at display rate, so the +panel stays steady even when the game drops frames. `mkw_vr_settings_panel_tests` covers the panel button in both controller modes, the release latch, -selection, scrolling and the canvas mapping; `gx_fifo_tests` covers where the panel lands in each eye. +selection, scrolling and the canvas mapping; `gx_fifo_tests` covers where the panel lands in each eye +on the fallback path. `mkw_openxr_replay_tests` and `mkw_openxr_vulkan_replay_tests` cover the layer: +nothing made before the panel opens, the panel image of a cancelled frame never shown, no layer while +the panel is closed or has no place yet, and render-first pacing. ## The first-person camera By default the headset sits where Mario Kart's own chase camera sits, and `world_units_per_meter` of 500 presents the race as a small diorama on a table. Turning on `first_person` moves the camera -to the local driver's head instead, and switches the world scale to -`first_person_units_per_meter`, whose default of 30 is what makes the race read life-size from the -seat. It is a matter of taste rather than a property of the game, so the F10 bar exposes it. +to the local driver's head instead, at one of two seats: + +- `first_person_seat = "cockpit"`, the default, sits you at the driver's own eyes, behind the + steering wheel, at a life-size scale, so the wheel or handlebar is within reach of your hands. + The eye is measured once per race from the character's head bone, while the kart drives straight, + undamaged and at normal size, and then frozen; until then the bind pose, or the vehicle's authored + seat height, stands in. It is kept at least 0.45 m behind the wheel so a long face or a + leaned-forward riding pose cannot put it over the controls. The world scale is + `cockpit_units_per_meter` (default 100) multiplied by the character's eye height over 100 units, + so tall characters sit at a comparable height, and by the player's current size, so a lightning + strike or a mega mushroom resizes the view, the wheel and the grab reach together. The seat + follows the simulation's position and driving direction, never the animated chassis, so damage + spins and tricks do not throw it around. +- `first_person_seat = "custom"` places the head at `first_person_head_up_meters` and its two + companions in the kart's own frame, at `first_person_units_per_meter`. + +Both are a matter of taste rather than properties of the game, so the F10 bar exposes them. + +**Toggling it from a controller.** Clicking the right thumbstick turns first person on or off +exactly as the F10 checkbox does, and the choice is saved the same way. It works on the VR +controllers in either presentation (the right controller gives a short tick), and on any other +gamepad while VR is running. A click counts on release, and only if the left thumbstick stayed up +and the settings panel stayed closed throughout, so clicking both thumbsticks to open the panel in +gamepad mode never toggles the camera. A gamepad whose right thumbstick click is bound to a +GameCube control on its port, as a button or in an input expression, keeps it for the game instead. +Toggled in a menu, the change applies from the next race. `first_person_toggle_click = false`, or +the F10 checkbox under the camera toggle, turns the click off. `mkw_vr_camera_toggle_tests` covers +the click rule. The kart is selected through the game's local-screen-to-racer mapping, including online races where your racer is not slot zero. First person requires a locally controlled racer; spectating @@ -267,20 +373,26 @@ own per-eye delta. The kart's *physics* pose is used deliberately, not the anima animated frame would bob and lurch the camera. `first_person_rotation` decides where the view's orientation comes from, mirroring DolphinXR's -camera-anchor modes. `"yaw"`, the default, keeps the horizon level through a chase-camera tilt or a -banked corner. `"yaw_pitch"` adds the kart's climb, so a slope or a wheelie tips the view while a -banked corner still never rolls it. `"full"` takes the kart's whole orientation, banking included. +camera-anchor modes. `"yaw"` keeps the horizon level through a chase-camera tilt or a banked corner. +`"yaw_pitch"`, the default, adds the kart's climb, so a slope or a wheelie tips the view while a +banked corner still never rolls it: sitting in the cockpit, the vehicle's own climb reads as the +ground rising rather than as the view tipping. `"full"` takes the kart's whole orientation, banking included. All three are the same construction from a forward and an up axis, differing only in which pair they take: pairing a forward with world up is what removes roll. The headset always adds free look on top of whichever is chosen, and only the translation onto the head is common to all three. -The head's place in the kart is `first_person_head_up_meters` and its two companions, measured in -the kart's own frame; the F10 sliders exist because the comfortable value is a matter of taste and -is best judged from inside the headset. +In the cockpit, `"yaw"` takes the kart's own driving direction rather than the chase camera's +lagging heading, from the level seat frame, which also damps a damage spin. `"yaw_pitch"`, the +default, and `"full"` take the kart's live orientation about that same seat, keeping only its +stabilised position, so a wheelie, a slope or a spin moves the view with the vehicle. With the custom seat, the head's place in the kart is +`first_person_head_up_meters` and its two companions, measured in the kart's own frame; the F10 +sliders exist because the comfortable value is a matter of taste and is best judged from inside the +headset. The mode engages only in a single-screen race, the same content that already qualifies for -immersive stereo. Menus, split-screen, and the virtual-screen fallback are unaffected, and so is -the desktop mirror, which keeps showing the game's ordinary third-person view. If the kart or +immersive stereo. Menus, split-screen, `flat_screen`, and the virtual-screen fallback are +unaffected, and so is the desktop mirror, which keeps showing the game's ordinary third-person +view. If the kart or camera cannot be read the camera stays where the game put it rather than guessing. Your own driver sits exactly where your eyes are, so their head would fill the view. @@ -301,6 +413,95 @@ wide head turn in first person can reveal the edge of what the game decided to d rest of the race instrumentation, the object offsets this reads are specific to the project's supported PAL `RMCP01` translation. +## Steering wheel and hand steering + +Ported from [heurazy's mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port) +(GPL-3.0-or-later). It applies to the cockpit seat. + +**The wheel turns.** With `steering_wheel = true` (the default) the kart's steering wheel or the +bike's handlebar turns with your steering: the left stick's deflection at the full-lock angle +(`wheel_kart_degrees` 90, `wheel_bike_degrees` 45), eased so a flicked stick does not snap it round, +or the hands' own angle while they hold it. `native_steering_wheel = true` turns the vehicle's own +model. Karts bake the wheel into the body, so at the race draw boundary the runtime decodes the +body's MDL0 position arrays, turns only the disc around the authored hand grips on a copy, and hands +the copy to the GX thread; Aurora substitutes it into the draws that bind that array with the +player's own model-view matrix (`aurora_set_native_wheel_vertices`), checking each changed vertex's +matrix slot, so an opponent sharing the asset and other joints of the same draw are untouched. The +guest's own vertices are never written, and the copies are dropped after the frame's draws. Bikes +turn their handle part in the game already; its copy is only re-seated on the cockpit frame so the +bars stay with your hands while the bike banks. The wheel rides in the same frame as the view: the +level seat for `"yaw"`, the kart's own orientation for `"yaw_pitch"` and `"full"`. While no draw takes +the copy (for 30 frames running; the race's opening pan does this) a separate VR wheel stands in, +which is also what `native_steering_wheel = false` draws. The copy keeps being published, so the +vehicle's own wheel returns as soon as draws take it again, and the log notes both switches. + +The substitution is decided per draw, and a draw that folds into a neighbour renders through that +neighbour's array binding, so only draws that reached the same decision may merge. Deciding this +per array instead, and so refusing to merge every primitive that binds the vehicle's array, cost 6 ms +of GPU time a frame on a Quest 3 (a race frame has 228 such primitives, recorded once and replayed in +the mono pass and both eyes) and took a 56 FPS race down to 42. `debug.wiicompiled.fpslog 1` reports +the draw calls a frame and the primitives merged away, which is where that shows up first. + +The copy is matched against the race camera's view (`RaceCamera::GetViewMtx` with no dolly offset), +because the scene camera is only set once the draws run. The log reports, once a second, how far +that view is from the scene camera at the seal (`[mkw-vr] cockpit: race camera view vs scene view`) +and how many draws took the copy; the F10 bar shows the same under the steering-wheel settings. +Aurora adds a line after about half a second, five seconds and a minute of copies +(`Native steering wheel: N sets; draws binding a replaced array ...`) counting the draws that bound a +copied array, those that bound one outside the window it was set for, the matches, and how far the +closest position matrix was from the expected one; the first such line with a bound draw also prints +both matrices. + +**Hand steering.** `hand_steering` (on by default, and in WheelWizard's OpenXR VR settings and the +Quest launcher's Settings > VR, beside the seat) +lets you take hold of the wheel or handlebar with the tracked controllers. It costs nothing until a +grip actually takes hold: until then the stick steers as it always has. Squeeze a grip near it: +past 55 % squeeze, within `wheel_grab_distance` metres of its plane (default 0.35) and near the rim, +or near a bar end, scaled by `wheel_grab_assist`. Once taken, only letting go of the grip releases +it. One hand steers by its angle around the hub; two hands steer by the line between them, so leaning +or moving both arms together does not steer, and a hand joining, leaving or crossing the hub keeps +the steering where it was. Turning past full lock is kept, so retracing the gesture returns to the +same centre, while the game's steering saturates at full lock. `wheel_response` scales how quickly +the wheel follows, `wheel_tracking_grace` (seconds) how long a hand that loses tracking keeps hold, +and `wheel_haptics` gives a short pulse on grab and release. + +While the wheel is held it replaces the left stick's X axis, in both the Wii Remote and the gamepad +presentation, and the game keeps its own steering curve. The stick's Y axis still aims items, and a +holding grip no longer reaches the game (a shoulder on the gamepad; the Wii Remote presentation +leaves the grips unbound for this reason); the triggers, A and the right stick are unchanged. Releasing both grips gives steering back +to the stick. The settings panel withholds the wheel like any other input. + +**A USB wheel.** With a USB wheel and pedals set up (see the README), the wheel drives the race as +player 1's GameCube controller. The cockpit's wheel follows its calibrated steering, at the same +full-lock angle as the stick (`wheel_kart_degrees`, `wheel_bike_degrees`), and hand steering steps +aside while it drives. + +**Hands and the separate wheel.** Hands are drawn while hand steering is on: the runtime's own hand +mesh where it offers one (`XR_EXT_hand_tracking` and `XR_FB_hand_tracking_mesh`, requested only when +hand steering is on at launch), otherwise procedural gloves that curl with the squeeze. A Quest 3 +offers that mesh without the app declaring hand tracking, and the log says which is drawn +(`[mkw-vr] cockpit hands:`). Both close their fingers towards the palm: the mesh's joints point +-Z towards the fingertip and +Y out of the back of the hand, so flexion is negative about the +joint's own X, on both hands. They and the +separate VR wheel or handlebar travel with the stereo packet in metres in the seated frame, and each +eye draws them inside the scene's pass just before the first 2D-layer draw, depth-tested with the +world's own depth mapping, so the kart and the track hide them. Visible cockpit samples +also mark one stencil bit; virtual-screen draws test that bit for zero, so even depth-disabled +HUD elements and black screen effects cannot paint over the hands. Only stereo eye targets +use `Depth24PlusStencil8`; desktop/EFB depth stays unchanged. The mask is cleared once per +eye replay and retained across its passes. This adds no draw, full-screen copy, or render pass; +eye-format pipeline siblings share shader modules and are cached when recording the game +frame (`aurora-main/lib/gfx/cockpit.hpp`). The anchor also carries the frame's exact world scale +(`aurora_set_stereo_scene_anchor_scaled`), and Aurora rescales each eye's head translation to it, so +a scale change between the XR packet and the frame cannot misplace the hands. + +The guest offsets involved (driver, movement, damage, grip frames, bike handle, driver bones and +their world matrices) are PAL `RMCP01` constants listed with the leaf getter or constructor that +proves each in `runtime/src/vr/mkw_vr_first_person.cpp`. `mkw_steering_wheel_tests`, +`mkw_vr_cockpit_tests` and `mkw_vr_hand_steering_tests` cover the grab model, the seat and wheel +geometry and the hand-off to the game; `gx_fifo_tests` covers the per-draw substitution and the +overlay geometry, and `cockpit_gpu_smoke` its depth test on a real GPU. + ## Presentation policy The runtime deliberately fails safe instead of guessing which Mario Kart camera is active: @@ -311,6 +512,10 @@ The runtime deliberately fails safe instead of guessing which Mario Kart camera confirm exactly one distinct race camera for the current GX frame. - Leaving the race or observing zero or multiple cameras immediately returns presentation to the virtual screen. Session/runtime loss safely tears down XR and continues on the desktop mirror. +- `flat_screen` clears the policy's `immersive_races`, so a race stays on the virtual screen + however complete the observations are. Changing it advances the safety generation like any + other change of presentation, and the pacing thread still treats that race as a race: pipeline + caches are not stored mid-race on the virtual screen either. Aurora records the original GX frame once and replays it for both OpenXR eyes. Perspective GX draws receive asymmetric headset projections, while the game's 2D layer goes on a fixed virtual screen @@ -320,6 +525,33 @@ short-lived immutable stereo packet. Each sealed GX frame and immersive packet c policy-generation tag; a mismatch is rendered in mono and the acquired XR frame is canceled, so an asynchronous menu/race transition cannot replay race transforms over unsafe content. +With interpolation off, PC (D3D12 and Windows Vulkan) and standalone (Android Vulkan) pace render-first: +the pacing thread locates views for an estimated display time (two periods past the last +prediction), hands Aurora a packet without leaving a compositor frame open, and waits for +rendering. A 50 ms stall repeats the retained layer; cancellation also advances a keep-alive +cycle to refresh timing. Once rendering is submitted, the thread calls xrWaitFrame and +xrBeginFrame, completes backend-specific copy/release work, and ends the frame using the +packet's original render poses with the current compositor display time. + +Android Vulkan renders into shared buffers and copies them into newly acquired XR images afterward. +Both PC bindings acquire images from their non-retained swapchain pair before rendering; Aurora +queues the copy on the session's queue before reporting completion. PC therefore needs no additional +copy in the short compositor cycle. Pending images remain acquired and separate from the +retained pair until completion or confirmed cancellation before encoding. GPU failure still +requires the existing queue-drain teardown. Rendered poses keep the session/reference-space +serials recorded when the packet was prepared, so changes during rendering invalidate them. + +VR interpolation keeps the frame-first order on both backends because it renders for the +frame's own predicted display time. The log announces `OpenXR D3D12 pacing: render-first` (or +`OpenXR Vulkan pacing: …` on the Vulkan binding) or +`frame-first (VR interpolation)` on each transition. For PC testing, disable **VR** frame +interpolation for a race capture; changing desktop interpolation alone does not select this +path. Menus use render-first even when VR interpolation is configured for races. Compare the +new diagnostic `open`, `end-gap`, `late`, and stage timings against a frame-first capture on +the same course and settings. Shorter `open` alone does not prove fewer black frames: rendering +and xrWaitFrame still take time outside that interval. Hardware testing is needed to measure +latency, runtime throttling and visible blackouts. + With VR interpolation enabled, Aurora retains each sealed race's command stream and matched previous/current transform uniforms. New OpenXR packets wake the frame worker between game frames. It interpolates at the requested display time, then applies that packet's head pose and @@ -339,7 +571,8 @@ the pacing thread continues submitting the last completed layer. A stall alone n desktop fallback after 250 ms. Before the first valid image, when OpenXR requests no rendering, or after a session/reference-space -change invalidates the retained content, frames can still have no layers. Actual runtime or GPU +change invalidates the retained content, frames can still have no layers (on the Quest outside a +race, only the passthrough layer while `passthrough` is on, so a recenter does not flash black). Actual runtime or GPU submission failures retain the safe teardown path. This does not detect black images rendered by the game itself, and cannot keep submitting if the entire process or XR runtime is suspended. All OpenXR session and swapchain calls remain on their owning thread. @@ -401,22 +634,49 @@ When it is on, `console.log` receives lines tagged `[runtime] [xr-diag]` | Field | Meaning | | --- | --- | -| `Hz`, `cycles` | Display rate from the predicted display period; compositor cycles (xrWaitFrame/xrEndFrame pairs, repeats included). | +| `predicted-rate`, `cycles` | Reciprocal of the runtime's predicted display period, **not necessarily physical headset refresh rate**; compositor cycles (xrWaitFrame/xrEndFrame pairs, repeats included). | | `skipped-slots` | Display slots the predicted display time jumped over: the runtime throttled or dropped frames. | | `late` | Frames whose xrEndFrame came after their predicted display time (needs `XR_KHR_win32_convert_performance_counter_time` or `XR_KHR_convert_timespec_time`). | | `layers new/repeat/empty` | Cycles ending with a newly rendered layer, the retained layer again, or no layer at all (black). | | `discarded`, `layer-rejected` | Retained layers dropped by a session or reference-space change; rendered layers not submitted (invalid pose or views, failed release). | | `wait-frame`, `open`, `end-call` | Time blocked in xrWaitFrame, from xrBeginFrame to xrEndFrame, and inside xrEndFrame. | | `end-margin`, `end-gap` | Predicted display time minus the xrEndFrame time; interval between xrEndFrame calls. | -| `pickup`, `render` | Stereo packet published until Aurora's frame worker takes it (without interpolation this includes waiting for the next 60 Hz game frame); taken until the eye copy is submitted. | +| `pickup`, `render` | Stereo packet published until Aurora's frame worker takes it (without interpolation this includes waiting for the next 60 Hz game frame); taken until the pacing thread observes the submission result. These are CPU wall times for completed submissions, **not GPU timestamps**; canceled packets are measured separately below. | | `acquire`, `release` | Swapchain image acquire+wait and release. | | `keepalive` | Retained-layer repeats while Aurora was still encoding past the 50 ms keep-alive. | -| `packet-unused`, `packet-rejected`, `submit-failed` | Packets no game frame took within 50 ms; packets Aurora took but rendered mono (content tag or transform check); failed stereo copies. | +| `packet-unused`, `packet-rejected`, `submit-failed` | Packets not picked up before cancellation; packets picked up but not encoded by the bridge before cancellation (the precise rejection cause is not known); failed stereo copies. | | `interp-skip` | Cycles the VR interpolation rate cap chose not to render. | | `frames immersive/screen` | Cycles per presentation mode; `not-rendered` counts cycles without views to render. | | `no-orientation`, `no-position` | Cycles whose head orientation or position was not valid. | | `suppressed` | Event lines dropped by the rate limit. | +- **Stage timings.** A separate `[xr-diag] stages ms` line accompanies each nonempty + window, independently of the event rate limit. Each field is `median/worst` in ms; + `@cycle=N,t=Ts` identifies the worst call's diagnostic cycle and completion time + since logging/session reset. The main summary also includes the last `cycle` and `t`. + Cycle 0 is before the first wait; work between cycles belongs to the previous cycle. + These are wall times, including time the OS did not schedule the thread. Nested + measurements (notably `sync-actions` within `input-sync`) must not be added together. + +| Stage | What it isolates | +| --- | --- | +| `poll-events`, `begin-call`, `locate-views` | Event polling, the xrBeginFrame call itself, and xrLocateViews. | +| `input-sync`, `sync-actions` | Complete input update and its xrSyncActions call. | +| `publish`, `withdraw` | Packet construction/publication and withdrawal, including mutex waits. | +| `set-targets` | D3D12/Vulkan bridge target registration, including its mutex wait. | +| `submission-wait` | Actual time waiting for a render result, including timeout paths; compare against the requested 50 ms. | +| `cancel` | Bridge cancellation attempt, whether it succeeds or fails. | +| `cancel-age` | Publication to cancellation, including packets never picked up. | +| `cancel-pickup`, `cancel-after-pickup` | Publication to pickup and pickup to cancellation for consumed, canceled packets. | + +For a blackout report, enable logging before entering a race, reproduce the blackout, +and export the logs immediately afterward. Include the approximate time and whether +both eyes and the desktop mirror went black. Check `frames immersive` is nonzero for +an immersive-race capture. A large stage maximum identifies where the pacing thread +spent time, but cannot distinguish API blocking from OS scheduling without a system +trace. No empty layers does not rule out black image contents or compositor/display +problems. This instrumentation does not change frame pacing or inspect image pixels. + - **Event lines.** At most 8 per second; the rest are counted in `suppressed`. They report late frames, skipped display slots, stalls (more than 2.5 display periods, and at least 25 ms, between xrEndFrame calls), empty frames and their reason, discarded retained layers, rejected layers, @@ -438,11 +698,58 @@ The current `console.log` is copied through a shared-read stream while it is sti The copy runs on SDL's dialog thread (`runtime/src/log_export.cpp`), and the outcome is shown under the button. `mkw_openxr_diagnostics_tests` and `mkw_log_export_tests` cover both without a headset. +## Windows Vulkan + +`video.graphics_api = "vulkan"` selects a second Windows binding, `runtime/src/vr/openxr_vulkan_win32.cpp`, +with the same pacing thread, retained-layer protocol and policy as D3D12 +(`runtime/include/vr/openxr_windows.h` picks the backend at startup). It is opt-in. It has raced on +a headset (SteamVR/OpenXR with a PlayStation VR2): immersive projection held the headset's full +90 Hz with no skipped display slots, and a 646-second session recorded no rejected or discarded +layers and no failed submissions. Other runtimes are still unexercised. + +**Why a custom Dawn.** The pinned prebuilt Dawn DLL exposes no native Vulkan device, so +`aurora-main/patches/dawn` adds a small versioned C ABI to the pinned Dawn source +(`aurora_dawn_vulkan_abi.h`, `AURORA_DAWN_VULKAN_ABI = 1`): hooks that let the OpenXR runtime +create Dawn's `VkInstance`, choose the physical device and create the `VkDevice`; wrapping of a +borrowed `VkImage` as a Dawn texture; the release barrier back to `COLOR_ATTACHMENT_OPTIMAL`; a +device-guard lock; and a queue drain. `Launcher/Build-DawnVulkan.ps1` builds that DLL from the pinned +revision on a machine with Visual Studio 2022, Python 3.12+ and CMake, and writes `aurora-vulkan.json` +(revision, ABI, DLL hash). `Launcher/Prepare-Dependencies.ps1 -DawnVulkanPackage ` installs it as +`dawn_prebuilt` in a fresh dependency destination after checking that provenance; re-harvest +`native_prebuilt` afterwards because the archives are pinned to the Dawn DLL hash. The runtime +build also fetches Vulkan headers (`vulkan_headers` dependency). + +**Startup.** `QueryGraphicsRequirements` loads `xrGetVulkanGraphicsRequirements2KHR`, +`xrCreateVulkanInstanceKHR`, `xrCreateVulkanDeviceKHR` and `xrGetVulkanGraphicsDevice2KHR`, then +installs the hooks in Dawn. Without the custom DLL it fails with *"requires the custom Dawn library +with Aurora Vulkan ABI 1"* and the game continues on the desktop renderer. During +`aurora_initialize` the runtime creates Dawn's instance (Vulkan 1.2 is requested when the loader +and runtime allow it, so that timeline semaphores, which PC runtimes create on the application's +device, are a core feature the device hook can enable) and device on the runtime's physical GPU. +`BindAurora` confirms that Dawn's physical device is the one the runtime selected, creates the +session on Dawn's graphics queue, picks the sRGB sibling of Aurora's UNORM colour format (with +`XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT`) so the compositor decodes the gamma-encoded bytes, and +enables the bridge. + +**Frames.** Each acquired XR image is wrapped once as a Dawn texture and reused. Aurora's frame +worker records the eye copies into its own command buffer; immediately after `queue.Submit`, still +under Aurora's submit mutex, the bridge appends the release barrier through Dawn's queue and +publishes the token that the pacing thread's `WaitForSubmission` consumes. The runtime may use +the VkQueue only inside `xrBeginFrame`, `xrEndFrame`, `xrAcquireSwapchainImage` and +`xrReleaseSwapchainImage`, so `OpenXRRuntime::LockGraphicsQueue` holds Dawn's device guard around +exactly those four calls and never across `xrWaitFrame` or `xrWaitSwapchainImage`. + +**Tests.** `mkw_openxr_vulkan_replay_tests` compiles the real backend against the deterministic +compositor of the D3D12 replay tests, including the queue-guard requirement on acquire and release. +`vulkan_native_bridge_smoke` (aurora, `AURORA_GPU_SMOKE_TESTS=ON`, real GPU, no headset) drives the +custom DLL's ABI through three borrowed-image copy/readback cycles; run it with that DLL beside it. + ## Backend status | Backend | Status | | --- | --- | | Windows D3D12 | Implemented: same-adapter, same-device asynchronous OpenXR submission. | +| Windows Vulkan | Implemented, opt-in (`video.graphics_api = "vulkan"`): the runtime creates Dawn's Vulkan instance and device through `XR_KHR_vulkan_enable2`, eyes are copied on the same queue, and Dawn's device guard is held around the four queue-touching OpenXR calls. Needs the custom Dawn from `Launcher/Build-DawnVulkan.ps1`. Raced on SteamVR/PSVR2 at the headset's full rate; other runtimes unexercised. See [Windows Vulkan](#windows-vulkan). | | Android Vulkan (Meta Quest) | Implemented and running on a Quest 3: the OpenXR side owns its own Vulkan device (`XR_KHR_vulkan_enable2`, `XR_KHR_vulkan_enable` fallback) and shares eyes with Dawn through `AHardwareBuffer`s ordered by sync-fd fences. Controllers arrive through OpenXR actions as a virtual SDL gamepad. See `docs/quest-port.md`. | | Linux Vulkan | Not wired. The pinned Dawn package does not expose a native Vulkan device, and the AHardwareBuffer bridge is Android-only; a dma-buf/opaque-fd variant of the same design would cover desktop Linux. | | Other platforms | Not wired yet. | @@ -507,9 +814,13 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging is not implemented. - Scene-specific comfort options, culling fixes and replay/spectator classification are future work. -- The headset settings panel is drawn into the eye images rather than submitted as its own quad - layer, so its text is resampled once more than a compositor layer's would be. It has no laser - beam, only the cursor on the panel itself, and text fields cannot be typed into without a keyboard. +- Hand steering works on a Quest 3 (2026-09-22): the kart's own wheel animated (228 draws a frame, + the race camera's view matching the scene's exactly) and the wheel can be grabbed and turned. In + that race the driver's eye was never calibrated, so the fallback placed the wheel centre about + 13 cm above eye level. Bikes and Quacker, and the PC, are still unvalidated. Hand steering needs + analog grips (Touch); the simple controller profile cannot grab. +- The headset settings panel has no laser beam, only the cursor on the panel itself, and text fields + cannot be typed into without a keyboard. - The desktop window remains available as a mirror/fallback. OpenXR diagnostics are written to the normal run log under diff --git a/README.md b/README.md index cbe5d91..a6a66bc 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,13 @@ The graphics layer is built on Play at several times the console's resolution. **Experimental OpenXR VR.** -Windows builds can render through a D3D12 OpenXR runtime without CPU readback. Menus and +Windows builds can render through an OpenXR runtime on D3D12, or on Vulkan with a custom Dawn +build, without CPU readback. Menus and unsupported scenes appear as a head-locked virtual screen; a validated single-camera race switches to immersive stereo rendering. VR is opt-in and falls back to the normal desktop renderer if the -runtime or headset is unavailable. See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the +runtime or headset is unavailable. In first person you sit in the cockpit, where the steering wheel +or handlebar turns with your steering, and hand steering by heurazy lets you grab it with the +tracked controllers and turn it. See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the current limitations. **Music ducking.** @@ -97,6 +100,38 @@ Known limitations of the Wii Remote path: - Turn the Wii Remote support off in that menu if you use a Mayflash DolphinBar, which already presents the remote as a regular gamepad. +**USB steering wheels and pedals.** +Ported from heurazy's [mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port). +Open **F10 > Controllers > USB wheel and pedals (player 1)**; it is also in the headset's settings +panel. Pick the steering device and axis and record full left, full right and centre, then each +pedal's released and fully pressed positions. Assign the right paddle to drift and the left paddle to +items; trick, confirm, pause and back are optional. Any wheel SDL sees as a joystick works this way, +with no gamepad mapping: separate USB pedals, reversed axes and combined pedal axes (select the same +axis for both pedals) all calibrate the same. The settings are saved in `PhysicalWheel.toml` beside +`Config.toml`. + +The wheel is player 1's GameCube controller. Press its confirm button at the title screen so the game +uses a GameCube controller; its D-pad, confirm and back then work the menus. In a race it owns +steering and the pedals. The brake pedal brakes, then reverses, and beats the accelerator and drift. +In VR, the cockpit's wheel turns with it and hand steering steps aside. Setting the VR controllers to +**Gamepad** keeps them for menus, pause and item aiming alongside the wheel. Light vibration is +optional, off by default, capped at 15 % and follows the game's own rumble. No centering spring or +steering force is requested. + +Logitech wheels (G29, G920, G923, G27, G25, Driving Force GT, PRO Racing Wheel) are recognised by SDL +as wheels and marked "(wheel)" in the device list. This has not been tried on a physical wheel yet: +- Install Logitech G HUB (Logitech Gaming Software for a G27 or G25). Without the driver a Logitech + wheel starts in a compatibility mode, typically with a smaller rotation range and both pedals on + one axis. A G920 or G923 for Xbox also starts as an Xbox controller, which the game would read as + an ordinary pad. +- Set a G29's mode switch to PS3 on PC. +- Full lock is wherever you record full left and right. Recording them a quarter turn each way + (90°) matches the VR cockpit's wheel, or lower the operating range in G HUB. +- A Driving Force Shifter's gears reach the game as buttons of the wheel and can be assigned like + any other. A gear stays pressed while it is engaged: on the item button it keeps the item held + behind you until you shift back to neutral. The clutch is not used. +- Turn on the centering spring in G HUB if you want the wheel to self-centre. + ## Requirements - Windows 10 or 11, 64-bit @@ -131,7 +166,8 @@ Saves and Miis use the normal installation's effective NAND; Retro Rewind retain XML-directed saves and ghosts. Graphics, VR preferences, caches, and compiled binaries stay separate. Uninstalling either backend in WheelWizard VR preserves configuration and shared progress. -Managed VR launches enable OpenXR with D3D12. If the runtime or headset is unavailable, the game +Managed VR launches enable OpenXR with D3D12; the Vulkan binding is opt-in through +`video.graphics_api` (see [OPENXR.md](OPENXR.md)). If the runtime or headset is unavailable, the game continues on the desktop and displays the failure briefly; **F10 → VR** retains the explanation. See [OpenXR configuration](OPENXR.md) and [distribution and validation](DISTRIBUTION.md). @@ -232,6 +268,8 @@ All translated output is verified against real hardware behavior and most import aurora's Direct3D, Vulkan and OpenGL backends. - **[OpenXR](https://www.khronos.org/openxr/)** - the Khronos cross-platform API used by the experimental VR renderer. +- **heurazy** - the VR cockpit's turning steering wheel and hand steering, ported from + **[mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port)** (GPL-3.0). - **[Dolphin Emulator](https://github.com/dolphin-emu/dolphin)** - an invaluable reference for Wii hardware behavior during development, plus the source of the free DSP coefficient ROM and the unmodified default WiiConnect24 bootstrap tree bundled with the runtime. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d606612..c3893e6 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -71,6 +71,23 @@ The runtime's Riivolution patch handling is a port of Dolphin's (both marked `SPDX-License-Identifier: GPL-2.0-or-later`). Source: +### heurazy's mario-kart-wii-VR-port - GPL-3.0-or-later + +- Source: +- Author: heurazy +- License: GNU General Public License v3.0 or later, the same license as WiiCompiled. + +The VR cockpit's steering wheel and hand steering are ported from this project: the grab-and-turn +model (`runtime/include/vr/steering_wheel.h`), the native wheel vertex rotation +(`runtime/include/vr/native_wheel_mesh.h`), the level seat (`runtime/include/vr/cockpit_stabilizer.h`), +the runtime hand-mesh loader (`runtime/include/vr/openxr_hand_mesh.h`), the per-draw substitution +(`aurora-main/include/aurora/native_wheel_match.hpp`, `aurora-main/lib/gx/native_wheel.hpp`), the +cockpit overlay renderer (`aurora-main/lib/gfx/cockpit.hpp`), their tests, and the seat, eye and +wheel geometry and guest reads in `runtime/src/vr/mkw_vr_first_person.cpp` and +`runtime/include/vr/mkw_vr_first_person.h`. The USB wheel and pedal support is ported from it too +(`runtime/include/physical_wheel.h`, `runtime/src/physical_wheel.cpp` and their test). The files +carry that attribution in their headers. + ### pugixml - MIT Copyright (c) 2006-2025 Arseny Kapoulkine. @@ -241,6 +258,12 @@ Not code, but the documentation this project depends on: - [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) by ZPL - the mod distribution this project can build as a static profile. No Retro Rewind content is redistributed here; users supply their own copy. +- The references heurazy's mario-kart-wii-VR-port credits for the cockpit, none of whose source is + compiled into this repository: + [AnimalCrossing-VR-MR-Standalone](https://github.com/heurazy/AnimalCrossing-VR-MR-Standalone) + (OpenXR hand meshes), [Cyberpunk VR port](https://github.com/dariulone/cyberpunk-vr-port) + (squeeze-to-grab steering) and [Pulsar](https://github.com/MelgMKW/Pulsar) (Mario Kart Wii class + layouts). --- diff --git a/android/QuestGameKit.psm1 b/android/QuestGameKit.psm1 index 5d23a66..b04b406 100644 --- a/android/QuestGameKit.psm1 +++ b/android/QuestGameKit.psm1 @@ -524,6 +524,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/build.gradle.kts b/android/app/build.gradle.kts index 51f7cb5..efc0314 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -101,8 +101,8 @@ android { // Quest 2 ships Android 10 (API 29); AHardwareBuffer/Vulkan 1.1 need 26+. minSdk = 29 targetSdk = 34 - versionCode = 2 - versionName = "0.2.0-quest" + versionCode = 4 + versionName = "0.4.0-quest" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" for ((name, value) in discPins) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fcdebd2..b17aa00 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,6 +26,9 @@ + + diff --git a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt index e535c3e..ac215d7 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt @@ -33,6 +33,9 @@ object GameStorage { fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY) + /** Mods imported on the Patches page, one folder each, as WheelWizard keeps them on a computer. */ + const val MODS_DIRECTORY = "Mods" + /** 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") @@ -40,6 +43,11 @@ object GameStorage { fun modContentReady(context: Context, profile: GameProfile): Boolean = !profile.modPack || modCodePul(context).isFile + fun modsDirectory(context: Context): File = File(gameRoot(context), MODS_DIRECTORY) + + /** The pack's Patches folder, which its Riivolution XML maps onto the disc; refilled from Mods at each start. */ + fun patchesDirectory(context: Context): File = File(modDirectory(context), "Patches") + fun configFile(context: Context): File = File(gameRoot(context), "Config.toml") fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs") @@ -78,7 +86,7 @@ object GameStorage { // Written line by line: trimIndent runs after interpolation, so an interpolated line // would take the indent off every other one. val lines = mutableListOf( - "# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.", + "# WiiCompiled Quest configuration. Edit with the launcher or the in-game panel. After an adb push, run chmod 664 on it or the app can no longer save settings.", "[paths]", "dvd_root = \"${discDirectory(context).absolutePath}\"", ) 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 cead748..09b1623 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? { @@ -279,13 +286,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) { @@ -327,7 +403,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) @@ -345,7 +421,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/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index 2d536c6..84a5d78 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -28,7 +28,8 @@ import org.wiicompiled.quest.R /** * The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR), - * with a Home page that sets up and starts the game and a Settings page that edits Config.toml. + * with a Home page that sets up and starts the game, a Patches page that imports mods for Retro + * Rewind, and a Settings page that edits Config.toml. * * The APK carries no game code. Playing needs two things the player owns: the game files (DATA, * extracted from their disc image here or on a PC) and the game itself (libmain.so, built from @@ -41,14 +42,16 @@ import org.wiicompiled.quest.R */ class LauncherActivity : Activity() { - private enum class Page { Home, Settings } + private enum class Page { Home, Patches, Settings } /** What Home's main and secondary buttons do. */ private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset } private lateinit var navHome: View + private lateinit var navPatches: View private lateinit var navSettings: View private lateinit var homePage: View + private lateinit var patchesView: View private lateinit var settingsView: View private lateinit var trails: WheelTrailsView private lateinit var playButton: View @@ -64,6 +67,7 @@ class LauncherActivity : Activity() { private lateinit var homeTitle: TextView private lateinit var gameToggle: LinearLayout private lateinit var settings: SettingsPage + private lateinit var patches: PatchesPage /** The games this APK carries a kit for, and the one the player picked. */ private val profiles: List by lazy { GameProfile.available(this) } @@ -71,6 +75,8 @@ class LauncherActivity : Activity() { private var page = Page.Home private var launching = false + /** Play is copying the enabled mods into Retro Rewind's Patches folder before starting it. */ + private var installingPatches = false private var trailsAway = true private var setupKind: Class<*>? = null private var mainAction = Action.Play @@ -95,8 +101,10 @@ class LauncherActivity : Activity() { } navHome = findViewById(R.id.nav_home) + navPatches = findViewById(R.id.nav_patches) navSettings = findViewById(R.id.nav_settings) homePage = findViewById(R.id.page_home) + patchesView = findViewById(R.id.page_patches) settingsView = findViewById(R.id.page_settings) trails = findViewById(R.id.home_trails) playButton = findViewById(R.id.home_play) @@ -129,11 +137,15 @@ class LauncherActivity : Activity() { downloadModPack = ::downloadModPack, resetInstallation = ::resetInstallation, ) + patches = PatchesPage(this, patchesView) { + openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker) + } savedInstanceState?.getString(KEY_TAB)?.let { name -> SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) } navHome.setOnClickListener { showPage(Page.Home) } + navPatches.setOnClickListener { showPage(Page.Patches) } navSettings.setOnClickListener { showPage(Page.Settings) } playButton.setOnClickListener { perform(mainAction) } secondary.setOnClickListener { secondaryAction?.let(::perform) } @@ -182,8 +194,16 @@ class LauncherActivity : Activity() { @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - val uri = data?.data ?: return - if (resultCode != RESULT_OK) return + if (resultCode != RESULT_OK || data == null) return + if (requestCode == REQUEST_PATCH_FILES) { + // Several files arrive as clip data, a single one as the data URI. + val clip = data.clipData + val uris = if (clip != null) (0 until clip.itemCount).map { clip.getItemAt(it).uri } else listOfNotNull(data.data) + showPage(Page.Patches) + patches.importPicked(uris) + return + } + val uri = data.data ?: return val task = when (requestCode) { REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage @@ -197,8 +217,10 @@ class LauncherActivity : Activity() { private fun showPage(target: Page) { page = target navHome.isSelected = target == Page.Home + navPatches.isSelected = target == Page.Patches navSettings.isSelected = target == Page.Settings homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE + patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE if (target == Page.Home) { trailsAway = true @@ -209,6 +231,7 @@ class LauncherActivity : Activity() { private fun refresh() { when (page) { Page.Home -> refreshHome() + Page.Patches -> patches.refresh() Page.Settings -> settings.refresh() } } @@ -278,10 +301,14 @@ class LauncherActivity : Activity() { setup is GameSetup.State.Finishing -> getString(R.string.home_finishing) else -> getString(label(mainAction)) } + if (installingPatches) { + playButton.isEnabled = false + playText.setText(R.string.home_installing_patches) + } secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE secondaryAction?.let { secondary.setText(label(it)) } - progress.visibility = if (settingUp) View.VISIBLE else View.GONE + progress.visibility = if (settingUp || installingPatches) View.VISIBLE else View.GONE progress.isIndeterminate = setup !is GameSetup.State.Working if (setup is GameSetup.State.Working && setup.total > 0) { progress.progress = (setup.done * progress.max / setup.total).toInt() @@ -335,9 +362,10 @@ class LauncherActivity : Activity() { showBanner(getString(R.string.home_mod_needed_message), warning = true) gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true) gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing -> - showBanner(getString(R.string.home_setup_intro), warning = false) + showBanner(getString(R.string.home_setup_intro) + discMd5Note(), 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) + discStatus == GameStorage.DiscStatus.Missing -> + showBanner(getString(R.string.home_data_missing, disc) + discMd5Note(), warning = false) else -> showBanner(null) } @@ -500,6 +528,9 @@ class LauncherActivity : Activity() { } } + /** The clean disc's .iso hash, for the banners that ask for a disc image. */ + private fun discMd5Note(): String = "\n\n" + getString(R.string.disc_md5_note, getString(R.string.disc_md5)) + private fun showBanner(text: String?, warning: Boolean = false) { if (text == null) { dataBanner.visibility = View.GONE @@ -549,18 +580,19 @@ class LauncherActivity : Activity() { .show() } - private fun openPicker(requestCode: Int) { - // Disc images and game files have no reliable MIME type, so every file is offered - // and the task checks the name and contents. + private fun openPicker(requestCode: Int, multiple: Boolean = false, noPicker: Int = R.string.home_no_picker) { + // Disc images, game files and mod files have no reliable MIME type, so every file is + // offered and the task checks the name and contents. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("*/*") + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) try { @Suppress("DEPRECATION") startActivityForResult(intent, requestCode) } catch (e: ActivityNotFoundException) { Log.w(TAG, "No document picker", e) - Toast.makeText(this, R.string.home_no_picker, Toast.LENGTH_LONG).show() + Toast.makeText(this, noPicker, Toast.LENGTH_LONG).show() } } @@ -589,6 +621,66 @@ class LauncherActivity : Activity() { Toast.makeText(this, R.string.home_quest1_launch_from_library, Toast.LENGTH_LONG).show() return } + if (launching) { + return + } + // Resume only brings the running game back, and its files must not change under it. + if (profile.modPack && !isGameRunning()) { + preparePatches() + return + } + startGame() + } + + /** + * Retro Rewind reads its pack's Patches folder, so the enabled mods are copied into it before + * every start, as the PC launcher does (ModsLaunchService.PrepareModsForLaunch). With none + * enabled, a folder that still holds files is only cleared if the player says so. + */ + private fun preparePatches() { + val mods = ModLibrary.load(GameStorage.modsDirectory(this)) + when { + ModLibrary.shouldAskToClear(mods, GameStorage.patchesDirectory(this)) -> + AlertDialog.Builder(this) + .setTitle(R.string.patches_clear_title) + .setMessage(R.string.patches_clear_message) + .setPositiveButton(R.string.patches_delete) { _, _ -> installPatches(mods, clear = true) } + .setNegativeButton(R.string.patches_keep) { _, _ -> startGame() } + .show() + mods.any { it.enabled } -> installPatches(mods, clear = false) + else -> startGame() + } + } + + private fun installPatches(mods: List, clear: Boolean) { + if (launching) return + launching = true + installingPatches = true + refreshHome() + val modsDir = GameStorage.modsDirectory(this) + val patchesDir = GameStorage.patchesDirectory(this) + ModLibrary.background({ ModLibrary.prepareForLaunch(modsDir, patchesDir, mods, clear) }) { result -> + installingPatches = false + launching = false + if (isDestroyed) return@background + val error = result.getOrElse { it.message ?: it.toString() } + if (error != null) { + Log.w(TAG, "Mods could not be installed: $error") + refreshHome() + AlertDialog.Builder(this) + .setTitle(R.string.home_patches_failed) + .setMessage(error) + .setPositiveButton(android.R.string.ok, null) + .show() + return@background + } + Log.i(TAG, "Patches folder ready: ${mods.count { it.enabled }} of ${mods.size} mods enabled") + refreshHome() + startGame() + } + } + + private fun startGame() { if (launching) { return } @@ -634,6 +726,7 @@ class LauncherActivity : Activity() { const val PROCESS_EXIT_GRACE_MS = 1000L const val REQUEST_DISC_IMAGE = 1 const val REQUEST_GAME_PACKAGE = 2 + const val REQUEST_PATCH_FILES = 3 const val PREFERENCES = "launcher" const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME" diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt new file mode 100644 index 0000000..fe5abcf --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ModLibrary.kt @@ -0,0 +1,341 @@ +package org.wiicompiled.quest.launcher + +import android.os.Handler +import android.os.Looper +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.util.Locale +import java.util.concurrent.Executors +import java.util.zip.ZipFile + +/** + * The mods imported on the Patches page, kept the way WheelWizard VR keeps them on a computer + * (Features/Mods): one folder per mod under Mods/, holding the mod's files and a `.ini` with + * its state, so a Mods folder copied from one launcher reads the same in the other. + * + * Mods only change Retro Rewind. Before it starts, [plan] and [sync] flatten the enabled mods into + * the pack's Patches folder, which the pack's Riivolution XML maps onto the disc (/patches, /sound), + * as the PC launcher's ModsLaunchService does before every Retro Rewind launch. A file two mods + * both carry comes from the one higher in the list, which is the one with the lower priority. + */ +object ModLibrary { + + /** One imported mod, as its `.ini` describes it. Author and ModID only come from the PC's mod browser. */ + data class Mod( + val title: String, + val enabled: Boolean, + val priority: Int, + val author: String = NO_ID, + val modId: Int = -1, + ) + + /** One file picked for an import: its display name and how to read it. */ + class Source(val name: String, val open: () -> InputStream) + + enum class NameProblem { Empty, Exists, IllegalCharacters } + + private const val SECTION = "Mod" + private const val NO_ID = "-1" + + /** ModManager._illegalChars plus Windows' invalid file name characters, so a name travels to the PC. */ + private val ILLEGAL_NAME_CHARACTERS = ".~/\\<>:\"|?*".toSet() + + /** Archives the PC unpacks with SharpCompress; without it, only .zip can be opened here. */ + private val UNSUPPORTED_ARCHIVES = listOf(".7z", ".rar") + + // Metadata + + /** Every mod under [modsDir] (`/.ini`), in list order: by priority, top first. */ + fun load(modsDir: File): List = + modsDir.listFiles { file -> file.isDirectory } + .orEmpty() + .mapNotNull { folder -> + val ini = File(folder, "${folder.name}.ini") + if (!ini.isFile) return@mapNotNull null + runCatching { parseIni(ini.readText()) }.getOrNull() + } + .sortedWith(compareBy { it.priority }.thenBy { it.title.lowercase(Locale.ROOT) }) + + /** + * Reads what Mod.LoadFromIniAsync reads, with its defaults: enabled unless it says otherwise, + * priority 0 and ModID -1 when absent. Null without a name, which the PC skips as well. + */ + fun parseIni(text: String): Mod? { + val values = mutableMapOf() + var section = "" + for (raw in text.removePrefix("").lineSequence()) { + val line = raw.trim() + when { + line.isEmpty() || line.startsWith(";") || line.startsWith("#") -> Unit + line.startsWith("[") && line.endsWith("]") -> section = line.substring(1, line.length - 1).trim() + section.equals(SECTION, ignoreCase = true) && '=' in line -> + values[line.substringBefore('=').trim().lowercase(Locale.ROOT)] = line.substringAfter('=').trim() + } + } + val title = values["name"]?.takeIf { it.isNotBlank() } ?: return null + return Mod( + title = title, + // bool.TryParse: either word in any case, and anything else keeps the default. + enabled = when (values["isenabled"]?.lowercase(Locale.ROOT)) { + "false" -> false + else -> true + }, + priority = values["priority"]?.toIntOrNull() ?: 0, + author = values["author"] ?: NO_ID, + modId = values["modid"]?.toIntOrNull() ?: -1, + ) + } + + /** What Mod.SaveToIniAsync writes: the same keys, with .NET's True/False. */ + fun iniText(mod: Mod): String = buildString { + append("[").append(SECTION).append("]\n") + append("Name = ").append(mod.title).append('\n') + append("Author = ").append(mod.author).append('\n') + append("ModID = ").append(mod.modId).append('\n') + append("IsEnabled = ").append(if (mod.enabled) "True" else "False").append('\n') + append("Priority = ").append(mod.priority).append('\n') + } + + fun save(modsDir: File, mod: Mod) { + val folder = folder(modsDir, mod) + if (!folder.isDirectory) throw IOException("The folder of ${mod.title} is missing: ${folder.absolutePath}") + val ini = File(folder, "${mod.title}.ini") + val temporary = File(folder, "${mod.title}.ini.tmp") + temporary.writeText(iniText(mod)) + if (!temporary.renameTo(ini)) { + temporary.delete() + ini.writeText(iniText(mod)) + } + } + + fun folder(modsDir: File, mod: Mod): File = File(modsDir, mod.title) + + /** ModManager.ValidateModName: a new name must be non-empty, unused (any case) and a valid folder name. */ + fun validateName(name: String, mods: List): NameProblem? { + val trimmed = name.trim() + return when { + trimmed.isEmpty() -> NameProblem.Empty + mods.any { it.title.equals(trimmed, ignoreCase = true) } -> NameProblem.Exists + trimmed.any { it in ILLEGAL_NAME_CHARACTERS || it.code < 32 } -> NameProblem.IllegalCharacters + else -> null + } + } + + /** A starting name for the import dialog: the file's name without its extensions, made valid. */ + fun suggestName(fileName: String, mods: List): String { + val base = fileName.substringAfterLast('/').substringBefore('.').trim() + .map { if (it in ILLEGAL_NAME_CHARACTERS || it.code < 32) ' ' else it } + .joinToString("").trim() + return base.takeIf { validateName(it, mods) == null } ?: "" + } + + // Changes + + /** + * ModManager.ImportModFilesAsync: the picked files become one new mod, enabled, below every + * existing one. A picked .zip is unpacked into it, as the PC does with a mod it downloads. + * The files are gathered beside the Mods folder's other entries and only moved into place once + * all are in, so a failed import leaves nothing behind. + */ + fun import(modsDir: File, title: String, sources: List, mods: List): Mod { + val name = title.trim() + validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") } + if (sources.isEmpty()) throw IOException("No files were chosen.") + sources.firstOrNull { source -> UNSUPPORTED_ARCHIVES.any { source.name.endsWith(it, ignoreCase = true) } }?.let { + throw IOException("${it.name} is an archive this headset cannot open. Unpack it on a computer and import its files, or import a .zip.") + } + modsDir.mkdirs() + val staging = File(modsDir, ".$name.importing") + staging.deleteRecursively() + if (!staging.mkdirs()) throw IOException("Could not create ${staging.absolutePath}") + try { + for (source in sources) { + val fileName = source.name.substringAfterLast('/').substringAfterLast('\\') + if (fileName.isBlank() || fileName == "." || fileName == "..") throw IOException("A chosen file has no usable name.") + if (fileName.endsWith(".zip", ignoreCase = true)) { + unpack(source, staging) + } else { + source.open().use { input -> File(staging, fileName).outputStream().use { input.copyTo(it) } } + } + } + if (staging.walkTopDown().none { it.isFile }) throw IOException("There was nothing to import.") + val mod = Mod(name, enabled = true, priority = (mods.maxOfOrNull { it.priority } ?: 0) + 1) + val target = folder(modsDir, mod) + // No mod has this name, so a folder under it is what an interrupted import left. + target.deleteRecursively() + if (!staging.renameTo(target)) throw IOException("Could not move the mod into ${target.absolutePath}") + save(modsDir, mod) + return mod + } finally { + staging.deleteRecursively() + } + } + + /** Unpacks a picked .zip into [destination], refusing entries that would land outside it. */ + private fun unpack(source: Source, destination: File) { + // ZipFile reads the central directory, which every zip has; streaming fails on some. + val archive = File(destination, ".archive.zip") + try { + source.open().use { input -> archive.outputStream().use { input.copyTo(it) } } + ZipFile(archive).use { zip -> + for (entry in zip.entries()) { + if (entry.isDirectory) continue + val relative = entry.name.replace('\\', '/').trimStart('/') + if (relative.isEmpty() || relative.split('/').any { it == ".." }) { + throw IOException("${source.name} has a file outside its own folder (${entry.name}).") + } + val file = File(destination, relative) + file.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> file.outputStream().use { input.copyTo(it) } } + } + } + } finally { + archive.delete() + } + } + + fun delete(modsDir: File, mod: Mod) { + val folder = folder(modsDir, mod) + if (folder.exists() && !folder.deleteRecursively()) throw IOException("Could not delete ${folder.absolutePath}") + } + + /** ModManager.RenameModAsync: the folder and its `.ini` take the new name. */ + fun rename(modsDir: File, mod: Mod, newTitle: String, mods: List): Mod { + val name = newTitle.trim() + if (name == mod.title) return mod + validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") } + val renamed = mod.copy(title = name) + val from = folder(modsDir, mod) + val to = folder(modsDir, renamed) + if (!from.renameTo(to)) throw IOException("Could not rename ${from.absolutePath}") + File(to, "${mod.title}.ini").delete() + save(modsDir, renamed) + return renamed + } + + /** + * ModManager.DecreasePriorityAsync (up) and IncreasePriorityAsync (down): the mod swaps + * priorities with its neighbour. Returns the two changed mods, or nothing at either end. + */ + fun move(mods: List, mod: Mod, up: Boolean): List { + val neighbour = if (up) { + mods.filter { it.priority < mod.priority }.maxByOrNull { it.priority } + } else { + mods.filter { it.priority > mod.priority }.minByOrNull { it.priority } + } ?: return emptyList() + return listOf(mod.copy(priority = neighbour.priority), neighbour.copy(priority = mod.priority)) + } + + // Launch + + /** + * ModsLaunchService.PrepareModsForLaunch: the file name each enabled mod's files take in the + * Patches folder, and where each comes from. Mods are walked from the bottom of the list up and + * a later one replaces an earlier one's file, so the top of the list wins. Names compare + * without case, as on the PC and on the headset's shared storage. + */ + fun plan(modsDir: File, mods: List): Map { + val files = LinkedHashMap>() + for (mod in mods.sortedWith(compareByDescending { it.priority }.thenByDescending { it.title.lowercase(Locale.ROOT) })) { + if (!mod.enabled) continue + val folder = folder(modsDir, mod) + if (!folder.isDirectory) continue + val metadata = File(folder, "${mod.title}.ini").absolutePath + val contents = folder.walkTopDown() + .filter { it.isFile && !it.absolutePath.equals(metadata, ignoreCase = true) } + .sortedBy { it.relativeTo(folder).path.lowercase(Locale.ROOT) } + for (file in contents) { + val name = launchName(mod.priority, file.name) + val key = name.lowercase(Locale.ROOT) + files[key] = (files[key]?.first ?: name) to file + } + } + return files.values.associate { it } + } + + /** + * ModsLaunchService.GetLaunchPatchFileName: a modding archive (`..szs`) is prefixed + * with its mod's priority, so Pulsar can resolve two mods patching the same archive. + */ + fun launchName(priority: Int, fileName: String): String { + if (!isModdingArchive(fileName)) return fileName + return "$priority.${stripPriorityPrefix(fileName)}" + } + + private fun isModdingArchive(fileName: String): Boolean { + if (!fileName.endsWith(".szs", ignoreCase = true)) return false + val stem = fileName.substring(0, fileName.length - ".szs".length) + val separator = stem.lastIndexOf('.') + return separator > 0 && separator + 1 < stem.length + } + + private fun stripPriorityPrefix(fileName: String): String { + val digits = fileName.takeWhile { it.isDigit() }.length + return if (digits > 0 && digits < fileName.length && fileName[digits] == '.') fileName.substring(digits + 1) else fileName + } + + /** ModsLaunchService.ShouldAskToClearTargetFolder: no mod enabled, yet the Patches folder holds files. */ + fun shouldAskToClear(mods: List, patchesDir: File): Boolean = + mods.none { it.enabled } && patchesDir.listFiles { file -> file.isFile }.orEmpty().isNotEmpty() + + /** + * ModsLaunchService.CopyFinalFiles: the Patches folder ends up holding exactly [plan]'s files. + * Loose files no mod provides go; a file whose size and time already match is not copied again. + */ + fun sync(patchesDir: File, plan: Map) { + patchesDir.mkdirs() + if (!patchesDir.isDirectory) throw IOException("Could not create ${patchesDir.absolutePath}") + val wanted = plan.keys.map { it.lowercase(Locale.ROOT) }.toSet() + for (file in patchesDir.listFiles { file -> file.isFile }.orEmpty()) { + if (file.name.lowercase(Locale.ROOT) !in wanted && !file.delete()) { + throw IOException("Could not remove ${file.absolutePath}") + } + } + for ((name, source) in plan) { + val target = File(patchesDir, name) + if (target.isFile && target.length() == source.length() && target.lastModified() == source.lastModified()) continue + source.copyTo(target, overwrite = true) + // Without the time, every start would copy everything again; that is all it costs. + target.setLastModified(source.lastModified()) + } + } + + /** ModsLaunchService with the folder-clearing answer: null on success, otherwise the message. */ + fun prepareForLaunch(modsDir: File, patchesDir: File, mods: List, clear: Boolean): String? = try { + when { + mods.any { it.enabled } -> sync(patchesDir, plan(modsDir, mods)) + clear && patchesDir.exists() && !patchesDir.deleteRecursively() -> + throw IOException("Could not clear ${patchesDir.absolutePath}") + } + null + } catch (e: IOException) { + e.message ?: e.toString() + } + + // Background work + + private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "ModLibrary").apply { isDaemon = true } } + private val main by lazy { Handler(Looper.getMainLooper()) } + + /** True while an import runs; imports and launch preparation take turns on one thread. */ + @Volatile + var importing = false + private set + + /** Runs [work] off the main thread, one task at a time, and hands its result to [done] on the main thread. */ + fun background(work: () -> T, done: (Result) -> Unit) { + worker.execute { + val result = runCatching(work) + main.post { done(result) } + } + } + + fun importInBackground(modsDir: File, title: String, sources: List, done: (Result) -> Unit) { + importing = true + background({ import(modsDir, title, sources, load(modsDir)) }) { result -> + importing = false + done(result) + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt new file mode 100644 index 0000000..74734c6 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/PatchesPage.kt @@ -0,0 +1,359 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.app.AlertDialog +import android.content.res.ColorStateList +import android.net.Uri +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.PopupMenu +import android.widget.Switch +import android.widget.TextView +import android.widget.Toast +import java.io.File +import java.io.IOException +import kotlin.math.roundToInt +import org.wiicompiled.quest.GameStorage +import org.wiicompiled.quest.R + +/** + * The launcher's Patches page, WheelWizard's mods page (Views/Pages/ModsPage.axaml.cs) without its + * mod browser: Import turns picked files into a named mod, and each mod can be switched on or off, + * moved up or down the list, renamed or deleted. What the list means for the game happens at + * Play, in [ModLibrary.prepareForLaunch]. + */ +class PatchesPage( + private val activity: Activity, + root: View, + private val pickFiles: () -> Unit, +) { + + private val empty: View = root.findViewById(R.id.patches_empty) + private val content: View = root.findViewById(R.id.patches_content) + private val rows: LinearLayout = root.findViewById(R.id.patches_rows) + private val count: TextView = root.findViewById(R.id.patches_count) + private val enableAll: Switch = root.findViewById(R.id.patches_enable_all) + private val headerImport: TextView = root.findViewById(R.id.patches_import) + private val importButtons = listOf(headerImport, root.findViewById(R.id.patches_empty_import)) + + private val modsDir: File get() = GameStorage.modsDirectory(activity) + + /** The list as read for the rows on screen, top first. */ + private var mods: List = emptyList() + + init { + for (button in importButtons) { + button.setOnClickListener { if (!ModLibrary.importing) pickFiles() } + } + styleSwitch(enableAll) + // A click, not a checked change: refresh() sets the switch without meaning to change every mod. + enableAll.setOnClickListener { setAllEnabled(enableAll.isChecked) } + root.findViewById(R.id.patches_enable_all_label).setOnClickListener { + enableAll.toggle() + setAllEnabled(enableAll.isChecked) + } + } + + /** Rebuilds the list from the Mods folder, which adb or the PC launcher's files may have changed. */ + fun refresh() { + mods = ModLibrary.load(modsDir) + val hasMods = mods.isNotEmpty() + // As on the PC, the page's own Import moves to the top bar once there is a list. + empty.visibility = if (hasMods) View.GONE else View.VISIBLE + content.visibility = if (hasMods) View.VISIBLE else View.GONE + headerImport.visibility = if (hasMods) View.VISIBLE else View.GONE + count.text = mods.size.toString() + enableAll.isChecked = mods.all { it.enabled } + + val importing = ModLibrary.importing + for (button in importButtons) { + button.isEnabled = !importing + button.alpha = if (importing) 0.45f else 1f + button.setText(if (importing) R.string.patches_importing else R.string.patches_import) + } + + rows.removeAllViews() + if (!hasMods) return + rows.addView( + note(), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(12) + }, + ) + mods.forEachIndexed { index, mod -> + rows.addView( + row(mod, index), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + if (index > 0) topMargin = dp(3) + }, + ) + } + } + + /** The files the player picked for Import: asks for the mod's name, then copies them in the background. */ + fun importPicked(uris: List) { + if (uris.isEmpty() || ModLibrary.importing) return + mods = ModLibrary.load(modsDir) + val sources = uris.map { uri -> + ModLibrary.Source(displayName(uri)) { + activity.contentResolver.openInputStream(uri) ?: throw IOException("Cannot read $uri") + } + } + // Typing on a headset is slow, so a single file offers its own name to start from. + val suggested = if (sources.size == 1) ModLibrary.suggestName(sources[0].name, mods) else "" + nameDialog(R.string.patches_name_title, null, suggested, R.string.patches_import, { ModLibrary.validateName(it, mods) }) { name -> + Log.i(TAG, "Importing ${sources.size} file(s) as mod $name") + ModLibrary.importInBackground(modsDir, name, sources) { result -> + if (activity.isDestroyed) return@importInBackground + refresh() + result.onSuccess { mod -> + Toast.makeText(activity, activity.getString(R.string.patches_installed, mod.title), Toast.LENGTH_LONG).show() + }.onFailure { failure -> + Log.w(TAG, "Mod import failed", failure) + AlertDialog.Builder(activity) + .setTitle(R.string.patches_import_failed) + .setMessage(failure.message ?: failure.toString()) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + refresh() + } + } + + private fun row(mod: ModLibrary.Mod, index: Int): View { + val last = mods.size - 1 + val toggle = Switch(activity).apply { + isChecked = mod.enabled + styleSwitch(this) + contentDescription = mod.title + setOnCheckedChangeListener { _, checked -> setModEnabled(mod, checked) } + } + val title = TextView(activity).apply { + text = mod.title + setTextColor(activity.getColor(R.color.neutral_100)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + } + return LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(58) + setPadding(dp(14), dp(6), dp(8), dp(6)) + background = activity.getDrawable( + when { + last == 0 -> R.drawable.bg_row_single + index == 0 -> R.drawable.bg_row_top + index == last -> R.drawable.bg_row_bottom + else -> R.drawable.bg_row_middle + }, + ) + addView(toggle) + addView(title, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(14) + marginEnd = dp(8) + }) + addView(iconButton(R.drawable.ic_chevron_up, R.string.patches_move_up, index > 0) { move(mod, up = true) }) + addView(iconButton(R.drawable.ic_chevron_down, R.string.patches_move_down, index < last) { move(mod, up = false) }) + addView(iconButton(R.drawable.ic_more, R.string.patches_more, true) { anchor -> showMenu(anchor, mod) }) + setOnClickListener { toggle.toggle() } + } + } + + private fun iconButton(icon: Int, description: Int, enabled: Boolean, onClick: (View) -> Unit) = ImageView(activity).apply { + setImageResource(icon) + imageTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_300)) + background = activity.getDrawable(R.drawable.bg_nav_item) + contentDescription = activity.getString(description) + setPadding(dp(10), dp(10), dp(10), dp(10)) + isEnabled = enabled + alpha = if (enabled) 1f else 0.3f + setOnClickListener(onClick) + layoutParams = LinearLayout.LayoutParams(dp(42), dp(42)) + } + + private fun note() = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + background = activity.getDrawable(R.drawable.bg_banner_info) + setPadding(dp(14), dp(10), dp(14), dp(10)) + addView( + TextView(activity).apply { + setText(R.string.patches_note) + setTextColor(activity.getColor(R.color.neutral_300)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + }, + ) + } + + private fun showMenu(anchor: View, mod: ModLibrary.Mod) { + PopupMenu(activity, anchor).apply { + menu.add(0, MENU_RENAME, 0, R.string.patches_rename) + menu.add(0, MENU_DELETE, 1, R.string.patches_delete) + setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_RENAME -> rename(mod) + MENU_DELETE -> delete(mod) + } + true + } + show() + } + } + + private fun setModEnabled(mod: ModLibrary.Mod, enabled: Boolean) { + // Saved in place rather than rebuilt, so the switch keeps its animation. + val changed = mod.copy(enabled = enabled) + if (!change { ModLibrary.save(modsDir, changed) }) { + refresh() + return + } + mods = mods.map { if (it.title == mod.title) changed else it } + enableAll.isChecked = mods.all { it.enabled } + } + + private fun setAllEnabled(enabled: Boolean) { + change { + for (mod in mods) { + if (mod.enabled != enabled) ModLibrary.save(modsDir, mod.copy(enabled = enabled)) + } + } + refresh() + } + + private fun move(mod: ModLibrary.Mod, up: Boolean) { + change { ModLibrary.move(mods, mod, up).forEach { ModLibrary.save(modsDir, it) } } + refresh() + } + + private fun rename(mod: ModLibrary.Mod) { + nameDialog( + R.string.patches_rename_title, + activity.getString(R.string.patches_rename_message, mod.title), + mod.title, + R.string.patches_rename, + { name -> if (name.trim() == mod.title) null else ModLibrary.validateName(name, mods) }, + ) { name -> + change { ModLibrary.rename(modsDir, mod, name, mods) } + refresh() + } + } + + private fun delete(mod: ModLibrary.Mod) { + AlertDialog.Builder(activity) + .setTitle(activity.getString(R.string.patches_delete_title, mod.title)) + .setMessage(R.string.patches_delete_message) + .setPositiveButton(R.string.patches_delete) { _, _ -> + change { ModLibrary.delete(modsDir, mod) } + refresh() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + /** Runs one change to the Mods folder; false, with the reason shown, when it failed. */ + private fun change(edit: () -> Unit): Boolean = try { + edit() + true + } catch (e: IOException) { + Log.w(TAG, "Mod change failed", e) + Toast.makeText(activity, activity.getString(R.string.patches_change_failed, e.message ?: e.toString()), Toast.LENGTH_LONG).show() + false + } + + /** + * WheelWizard's TextInputWindow: a name field that keeps the dialog open, with the reason shown, + * until the name is one [validate] accepts. + */ + private fun nameDialog( + title: Int, + message: String?, + initial: String, + positive: Int, + validate: (String) -> ModLibrary.NameProblem?, + onAccept: (String) -> Unit, + ) { + val input = EditText(activity).apply { + setText(initial) + setSelection(text.length) + setHint(R.string.patches_name_hint) + setTextColor(activity.getColor(R.color.neutral_100)) + setHintTextColor(activity.getColor(R.color.neutral_500)) + isSingleLine = true + imeOptions = EditorInfo.IME_ACTION_DONE + } + val container = FrameLayout(activity).apply { + setPadding(dp(22), dp(8), dp(22), 0) + addView(input) + } + val dialog = AlertDialog.Builder(activity) + .setTitle(title) + .apply { message?.let { setMessage(it) } } + .setView(container) + .setPositiveButton(positive, null) + .setNegativeButton(android.R.string.cancel, null) + .create() + fun accept() { + val problem = validate(input.text.toString()) + if (problem != null) { + input.error = activity.getString( + when (problem) { + ModLibrary.NameProblem.Empty -> R.string.patches_name_empty + ModLibrary.NameProblem.Exists -> R.string.patches_name_exists + ModLibrary.NameProblem.IllegalCharacters -> R.string.patches_name_illegal + }, + ) + return + } + dialog.dismiss() + onAccept(input.text.toString().trim()) + } + input.setOnEditorActionListener { _, action, _ -> + if (action == EditorInfo.IME_ACTION_DONE) accept() + action == EditorInfo.IME_ACTION_DONE + } + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { accept() } + input.requestFocus() + } + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + dialog.show() + } + + private fun displayName(uri: Uri): String { + runCatching { + activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotBlank() }?.let { return it } + } + } + return uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "file" + } + + private fun styleSwitch(switch: Switch) { + switch.thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300) + switch.trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600) + } + + private fun checkedColors(checked: Int, unchecked: Int) = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(activity.getColor(checked), activity.getColor(unchecked)), + ) + + private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt() + + private companion object { + const val TAG = "WiiCompiledLauncher" + const val MENU_RENAME = 1 + const val MENU_DELETE = 2 + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt index 2ba8ee8..ee25954 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -114,18 +114,28 @@ class SettingsPage( } private fun buildVr() { - val firstPerson = { c: TomlConfig -> c.bool("vr", "first_person") ?: false } + // Flat Screen mode keeps races on the menu screen, which none of the race view rows reach. + val immersive = { c: TomlConfig -> !(c.bool("vr", "flat_screen") ?: false) } + val firstPerson = { c: TomlConfig -> immersive(c) && (c.bool("vr", "first_person") ?: false) } + // The steering wheel and hand steering belong to the cockpit seat. + val cockpit = { c: TomlConfig -> firstPerson(c) && stringIndex(c, "vr", "first_person_seat", SEATS) == 0 } section(R.string.section_vr_camera) { + toggle( + R.string.vr_flat_screen, R.string.vr_flat_screen_helper, + read = { !immersive(it) }, + write = { c, value -> c.setBool("vr", "flat_screen", value) }, + ) choice( R.string.vr_camera, R.string.vr_camera_helper, listOf(R.string.vr_camera_chase, R.string.vr_camera_first_person), - read = { if (firstPerson(it)) 1 else 0 }, + read = { if (it.bool("vr", "first_person") == true) 1 else 0 }, write = { c, index -> c.setBool("vr", "first_person", index == 1) }, + enabledIf = immersive, ) choice( R.string.vr_rotation, R.string.vr_rotation_helper, listOf(R.string.vr_rotation_yaw, R.string.vr_rotation_yaw_pitch, R.string.vr_rotation_full), - read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS) }, + read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS, ROTATION_DEFAULT) }, write = { c, index -> c.setString("vr", "first_person_rotation", ROTATIONS[index]) }, enabledIf = firstPerson, ) @@ -151,11 +161,26 @@ class SettingsPage( }, enabledIf = firstPerson, ) + choice( + R.string.vr_seat, R.string.vr_seat_helper, + listOf(R.string.vr_seat_cockpit, R.string.vr_seat_custom), + read = { stringIndex(it, "vr", "first_person_seat", SEATS) }, + write = { c, index -> c.setString("vr", "first_person_seat", SEATS[index]) }, + enabledIf = firstPerson, + ) + // heurazy's grab-and-turn wheel: runtime_config.h's kVrHandSteeringDefault is on. + toggle( + R.string.vr_hand_steering, R.string.vr_hand_steering_helper, + read = { it.bool("vr", "hand_steering") ?: true }, + write = { c, value -> c.setBool("vr", "hand_steering", value) }, + enabledIf = cockpit, + ) slider( R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0, read = { number(it, "vr", "lean_back_degrees", -45.0, 45.0, 0.0) }, format = { "%.0f°".format(it) }, write = { c, value -> c.setFloat("vr", "lean_back_degrees", value) }, + enabledIf = immersive, ) } section(R.string.section_vr_headset) { @@ -187,6 +212,7 @@ class SettingsPage( R.string.vr_hud_screen, R.string.vr_hud_screen_helper, read = { it.bool("vr", "hud_virtual_screen") ?: true }, write = { c, value -> c.setBool("vr", "hud_virtual_screen", value) }, + enabledIf = immersive, ) slider( R.string.vr_hud_distance, R.string.vr_hud_distance_helper, 0.5, 5.0, 0.1, @@ -200,6 +226,11 @@ class SettingsPage( format = { "%.1f m".format(it) }, write = { c, value -> c.setFloat("vr", "hud_width_meters", value) }, ) + toggle( + R.string.vr_passthrough, R.string.vr_passthrough_helper, + read = { it.bool("vr", "passthrough") ?: true }, + write = { c, value -> c.setBool("vr", "passthrough", value) }, + ) } } @@ -227,6 +258,11 @@ class SettingsPage( read = { it.bool("video", "skip_unready_pipelines") ?: true }, write = { c, value -> c.setBool("video", "skip_unready_pipelines", value) }, ) + toggle( + R.string.graphics_gx_thread, R.string.graphics_gx_thread_helper, + read = { it.bool("video", "gx_thread") ?: true }, + write = { c, value -> c.setBool("video", "gx_thread", value) }, + ) } } @@ -290,6 +326,7 @@ class SettingsPage( action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = idle) { selectDiscImage() } + info(R.string.about_disc_md5, activity.getString(R.string.disc_md5), stacked = true) // 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) @@ -356,6 +393,7 @@ class SettingsPage( } section(R.string.section_about_credits) { info(R.string.about_credit_title_vr, activity.getString(R.string.about_credit_vr), stacked = true) + info(R.string.about_credit_title_hand_steering, activity.getString(R.string.about_credit_hand_steering), stacked = true) info(R.string.about_credit_title_wiicompiled, activity.getString(R.string.about_credit_wiicompiled), stacked = true) info(R.string.about_credit_title_retro_rewind, activity.getString(R.string.about_credit_retro_rewind), stacked = true) info(R.string.about_credit_title_wheel_wizard, activity.getString(R.string.about_credit_wheel_wizard), stacked = true) @@ -676,6 +714,11 @@ class SettingsPage( private companion object { val ROTATIONS = listOf("yaw", "yaw_pitch", "full") + + /** runtime_config.h's kVrFirstPersonRotationDefault. */ + val ROTATION_DEFAULT = ROTATIONS.indexOf("yaw_pitch") + // The runtime's default ("cockpit") first. + val SEATS = listOf("cockpit", "custom") // The runtime's default ("boost") first: an absent key reads as index 0. val PERFORMANCE_LEVELS = listOf("boost", "sustained_high", "sustained_low", "power_savings", "default") val CONTROLLER_MODES = listOf("wii_remote", "gamepad") @@ -697,8 +740,9 @@ class SettingsPage( config.number(section, key)?.takeIf { it in min..max } ?: default /** Unrecognised strings fall back to the first (default) option, as in the runtime. */ - fun stringIndex(config: TomlConfig, section: String, key: String, values: List): Int = - values.indexOf(config.string(section, key)).coerceAtLeast(0) + fun stringIndex(config: TomlConfig, section: String, key: String, values: List, + default: Int = 0): Int = + values.indexOf(config.string(section, key)).takeIf { it >= 0 } ?: default fun resolution(config: TomlConfig): Double = config.number("video", "resolution_multiplier")?.takeIf { it in SUPPORTED_RESOLUTIONS } ?: 1.0 diff --git a/android/app/src/main/res/drawable/ic_chevron_up.xml b/android/app/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 0000000..61626f5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_file_import.xml b/android/app/src/main/res/drawable/ic_file_import.xml new file mode 100644 index 0000000..5dadba0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_file_import.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_more.xml b/android/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..103e350 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_patches.xml b/android/app/src/main/res/drawable/ic_patches.xml new file mode 100644 index 0000000..d41e7fb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_patches.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index 3c9b71a..245f874 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -61,6 +61,20 @@ android:text="@string/launcher_nav_home" /> + + + + + + + @@ -105,6 +119,11 @@ android:id="@+id/page_home" layout="@layout/page_home" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6bb1b61..ef107a9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ General Home + Patches Settings v%1$s @@ -97,6 +98,38 @@ Removes the game files, and if you tick them the built games and the Retro Rewind pack, so they can be set up again. Saves and settings stay. The game folder could not be prepared: %1$s + + Import + Importing… + No mods found + Mods can alter how the game works. Start importing your first mod by clicking the button below.\n\nAs on the computer, mods change Retro Rewind. Import the mod\'s files, or the .zip it came in. + Mods + Enable all + Each time Retro Rewind starts, the enabled mods are copied into its Patches folder. When two mods replace the same file, the one higher in the list wins. + Move up + Move down + More + Rename + Delete + Keep + Mod name: + Enter mod name… + Enter new name + Changing name from: %1$s + Mod name cannot be empty. + Mod name already exists. + Mod name contains illegal characters. + Mod \'%1$s\' installed successfully. + The mod could not be imported + The mod could not be changed: %1$s + Are you sure you want to delete %1$s? + Deleting is permanent and cannot be undone. + This headset has no file picker to choose mod files with. + Mods found + You are about to launch the game without mods. Do you want to clear your Patches folder? If you keep it, the game starts with the patches it holds still active. + Installing mods… + The mods could not be installed + VR Graphics @@ -110,6 +143,7 @@ WiiCompiled OpenXR VR Mario Kart Wii recompiled to native code, with an OpenXR renderer, running on this headset. No game code and no game data come with it: each game is built from the disc you own. Meta Quest port and the OpenXR VR renderer by iChris4. + The cockpit\'s turning steering wheel and hand steering by heurazy, from mario-kart-wii-VR-port (GPL v3). WiiCompiled, the static recompilation this is built on, by patchzyy and the static recompilation community. Retro Rewind by ZPL and team, the mod distribution this app can download and play. Wheel Wizard by Patchzy and WantToBeeMe, the PC launcher this panel is modelled on. @@ -122,6 +156,7 @@ GNU General Public License v3. Not affiliated with, endorsed by, or associated with Nintendo. Mario Kart Wii is a trademark of Nintendo. Game kit Meta Quest and VR + Steering wheel and hand steering WiiCompiled Retro Rewind Wheel Wizard @@ -139,6 +174,8 @@ Camera + Flat Screen mode + Plays races on the same flat screen as the menus instead of all around you. The camera settings below do not apply while it is on. Camera Ride behind the kart like the game, or sit in the driver\'s seat. Chase camera @@ -153,6 +190,12 @@ Nothing Driver Driver and kart + Seat + The cockpit puts you at the driver\'s eyes, life-size, with the steering wheel or handlebar turning within reach. Custom uses the head offsets from the in-headset settings. + Cockpit + Custom + Hand steering + In the cockpit, squeeze a grip near the steering wheel or handlebar to grab it, and turn it to steer. Releasing both grips gives steering back to the stick. Hand steering by heurazy. Lean back angle Tilts the race view back for playing reclined. 0 applies no tilt. @@ -178,6 +221,8 @@ How far the menu screen and race HUD sit in front of you. Screen width How wide the menu screen and race HUD are. + Passthrough around the menu screen + Shows your room through the headset\'s cameras around the menus instead of black. Races stay fully virtual, except in Flat Screen mode. Rendering @@ -190,6 +235,8 @@ Bloom\'s bright glow reads poorly in a headset, so it starts off. Prevent shader stutters Skips a draw for a moment while its shader compiles instead of pausing the game. + Graphics thread + Prepares the drawing on a second CPU core so busy scenes keep their speed. Turn off only to compare against the single-threaded path. Takes effect on the next launch. Controllers @@ -213,7 +260,7 @@ Z Left trigger C - Left grip + Right B Pointer and motion Aim and move the right controller Settings panel @@ -240,6 +287,10 @@ Logs Extract from disc image Replaces DATA with the files of your own PAL disc image. + + E7B1FF1FABB0789482CE2CB0661D986E + Your disc image must be the clean PAL game: as a .iso file its MD5 hash is %1$s. Compressed images (WBFS, RVZ…) have a different hash. + Clean PAL .iso MD5 Game Built by %1$s on %2$s Not installed yet diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index e9fee49..692b2ef 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -70,6 +70,24 @@ true + + +