Author SHA1 Message Date
DieMitchell 53e20f4feb Fix installation recovery and publish frame-beta-4 notes 2026-10-08 15:45:59 +02:00
mitch030504 d97f789dce Merge pull request #14 from mitch030504/claude/docs-and-credits-41qj9z
README: list the disc formats the installer accepts
2026-10-05 18:57:24 +02:00
Claude 4b5fe132bf README: list the disc formats --disc accepts
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Axv1Y43Lu5a5rSSLUyU5BB
2026-10-05 16:55:44 +00:00
mitch030504 568166c163 Merge pull request #13 from mitch030504/claude/docs-and-credits-41qj9z
Rewrite the README for frame-beta-3 and the update modes
2026-10-05 18:54:20 +02:00
Claude 5e055dde13 README: rewrite for frame-beta-3 and the update modes
Restructure the README around what a player does: choose where to build
(PC or the Frame), install, update, play, then settings, controls,
known issues and troubleshooting. Building on a server and by hand move
under "Other ways to build", and the upstream and credits notes are
merged into "What comes from where" and a short Credits section that
points at CREDITS.md.

Updating now shows the curl form of --update, which works without a
local copy of the script; the in-game Updates tab; and the one full
install needed when coming from frame-beta-2. Manual steps use the
frame-beta-3 tag, the install options are a table, and the Troubleshooting
section covers in-game update failures. Links to the old "The Android
flavour" section now point at "How it works".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Axv1Y43Lu5a5rSSLUyU5BB
2026-10-05 16:47:23 +00:00
mitch030504 b35243244f Merge pull request #12 from mitch030504/claude/install-update-mode-wm4qyu
Update the Frame install with --update, or from an Updates tab in the game
2026-10-05 18:37:58 +02:00
Claude 55d921a118 Add --update and --check to the install script, and an Updates tab in the game
--update reuses the options an install was made with (saved to install.conf in the work dir) and
builds nothing when the Frame already has the newest release and Retro Rewind pack; --check only
reports. The installed release is stamped as .release-tag beside the game on the Frame.

An install built on the Frame itself (--frame local) also gets a systemd user service the game's
new Updates tab starts: the update runs in the background at low priority, writes each step and
the build percentage to a status file the tab shows, and reopens the game at the end if it was
closed. Progress does not go through Steam notifications: on the Frame, Steam receives them from
steam_notif_daemon but does not display them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UyuWx5hNrMpf9R78FZFcbA
2026-10-05 16:27:15 +00:00
mitch030504 9be271d49a Merge pull request #11 from mitch030504/claude/docs-and-credits-41qj9z
Credit ported work and document today's merged changes
2026-10-05 18:24:31 +02:00
Claude 6a3441b616 Docs: credit ported work and cover today's merged changes
Add CREDITS.md, listing the projects the fork is built on and every
change ported from upstream WiiCompiled and other forks, with author and
source commit, taken from the commit messages. Link it from the README,
THIRD-PARTY-NOTICES.md and a new porting note in CONTRIBUTING.md.

Document adaptive_resolution in OPENXR.md, the 4 KiB page constant and
NEON mixing in the README, the Mozilla CA bundle in the notices, and draft
the frame-beta-3 release notes for what PRs #5 to #10 merged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Axv1Y43Lu5a5rSSLUyU5BB
2026-10-05 16:22:20 +00:00
mitch030504 58b6d81ed1 Merge pull request #10 from mitch030504/claude/project-thread-4jegmq
Port three small fixes from the DarthMDev and KartPad forks
2026-10-05 18:15:11 +02:00
Claude f7ca5ecbd6 Keep debug labels out of the ImGui passes in normal builds
Aurora's own debug groups only exist when AURORA_GFX_DEBUG_GROUPS is
defined, but the three ImGui passes (desktop overlay, host-frame overlay
and the headset settings panel) pushed and popped theirs in every build.
An Adreno 740 Android driver (512.676) faults in
vkCmdEndDebugUtilsLabelEXT on the first overlay pass (KartPad #321); the
Quest 3 uses the same GPU. Gate all three like the rest of Aurora.

Ported from chrissotraidis/wiicompiled (KartPad) 420b828, extended to the
two extra passes this fork has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rLZ24cFcqTNBmb4w2dpSq
2026-10-05 15:55:43 +00:00
Chris SotraidisandClaude Opus 5.5 c5f9ca3e2d Cache bounded DVD host file handles per thread
Ported from chrissotraidis/wiicompiled (KartPad) affabbd17756f2e38fb8679042895f287d666e18.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rLZ24cFcqTNBmb4w2dpSq
2026-10-05 15:54:42 +00:00
Chris SotraidisandClaude Opus 5.5 8e9e905b85 Decode Yaz0 through guarded host buffers
Ported from chrissotraidis/wiicompiled (KartPad) 97cdc9a3609b6d3ad43009f76be17361b1a9e93a.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rLZ24cFcqTNBmb4w2dpSq
2026-10-05 15:54:42 +00:00
Chris SotraidisandClaude Opus 5.5 03db602a05 Bulk-copy DVD DMA into non-executable guest RAM
Ported from chrissotraidis/wiicompiled (KartPad) ec14a0dddacbd1267de159215ba39d5f19633164.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rLZ24cFcqTNBmb4w2dpSq
2026-10-05 15:54:41 +00:00
DarthMandClaude Opus 5.5 77333709ae Keep a TLS session that failed a write instead of deleting its socket
When an mbed TLS write timed out, SslWrite called DeleteWiiSocket, which
closed the game's socket and cleared its SSL slot while the game still
held both ids. A later SSL_NEW could then hand the same slot out under
the old id. Now a failed write marks the session failed and shuts the
host socket down; later reads, writes and handshakes on it fail until the
game tears the session down itself.

Ported from DarthMDev/Wiicompiled a2ecc0f (network_ssl.cpp part) and
fcf8646, both on upstream PR #258.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rLZ24cFcqTNBmb4w2dpSq
2026-10-05 15:54:41 +00:00
mitch030504 cfd434fac8 Merge pull request #9 from mitch030504/claude/project-thread-lokepv
Port five small fixes from other WiiCompiled forks
2026-10-05 17:52:18 +02:00
Claude 93df677470 Fix the guest page size at compile time for the Steam Frame build
Every translated guest load and store tests RequiresCheckedAccess(). On
x86-64 and Windows that is a compile-time false, but on AArch64 it read
a global flag, which under PIE is a GOT load plus a byte load and a
branch on every access, re-read after every guest store. The flag is
only true on kernels with pages larger than 4 KiB.

The Steam Frame's SteamOS kernel uses 4 KiB pages (getconf PAGESIZE on
the headset reports 4096), so the native --headset steam_frame build now
folds the test away like x86-64 does. GuestFlat::Initialize checks the
page size at startup in that build and refuses to start with a clear
message if it is ever anything else. Android and other AArch64 builds
still probe at runtime.

Checked with clang --target=aarch64-linux-gnu -O2 -fPIE: a FlatRead32
loop loses both flag loads and the checked Memory::Read32 fallback.

Follows the finding from nx-mod/wiicompiled-nx 18ef16f6, which made the
flag hidden instead; a compile-time constant removes it entirely.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKSMYZU43wUfYtm1sxGsC
2026-10-05 15:22:17 +00:00
Claude 8f725001de Refill WC24 bootstrap files that were left empty
Bootstrap seeding only created missing files, so a WC24 file that had
been emptied (nwc24dl.bin, nwc24fl.bin, misc.bin) stayed at zero bytes
on every boot. WC24 rejects a zero-length download or friend list and
MKW reports that as a save error, which matters now that #7 lets KD
open while offline. A file is re-copied only when it is empty and the
payload's copy is not, so player data is never replaced.

Adapted from nx-mod/wiicompiled-nx d0149e66. That version falls through
to copy_file with copy_options::none, which fails on the existing empty
file; this one overwrites in that case only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKSMYZU43wUfYtm1sxGsC
2026-10-05 15:20:02 +00:00
Claude e49ec771c3 Add NEON forms of the AX mix kernels
On arm64 the AXWii mix (ramped mix-add, volume envelope, aux returns
and the big-endian bus marshalling) ran the scalar loops, since only an
AVX2 form existed. The Neon forms do eight 16-bit samples per step as
two int32x4 halves and keep the scalar loops for the tail.

The header part of rooklz's 43463622; its other changes are left out.
A new test checks every vector form against the scalar loops at every
tail length and at the ramp and clamp extremes; it passes under
qemu-aarch64 (NEON), with -mavx2 and with neither.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKSMYZU43wUfYtm1sxGsC
2026-10-05 15:18:43 +00:00
Claude db8afbb3a9 Don't write depth while the Z compare is off
On the hardware (and in Dolphin) GXSetZMode with the compare disabled
leaves the Z buffer alone even when the update bit is set. Aurora still
wrote depth, so a full-screen quad drawn that way (the darkening during
Stormship's lightning, for one) marked the whole screen nearest and
every depth-tested draw after it failed until it faded.

Ported from BlackAndBlue95/Strikers-WiiCompiled 369ec992. The pipeline
cache key already includes depthUpdate, so no cache version bump.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKSMYZU43wUfYtm1sxGsC
2026-10-05 15:16:25 +00:00
Claude 755fb0e332 Save Config.toml through a temporary file and rename
The in-game settings bar rewrote Config.toml in place with a truncating
open, so a crash or a full SD card mid-save could leave it empty or cut
short. Settings are now written beside the file and renamed over it, and
a file that exists but can't be read is no longer rewritten from nothing.

Ported from BlackAndBlue95/Strikers-WiiCompiled 435daf3e (the
runtime_config.h part only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NKSMYZU43wUfYtm1sxGsC
2026-10-05 15:16:02 +00:00
mitch030504 f2f4f081c8 Merge pull request #8 from mitch030504/claude/project-thread-by97b7
Port upstream renderer fixes and stop spinning on staging buffer maps
2026-10-05 15:48:16 +02:00
Claude 897899a5f9 Sleep instead of spinning while waiting for a staging buffer
When the GPU is two frames behind, begin_frame waits for the next
staging buffer's MapAsync. It used to spin on ProcessEvents, holding a
whole core busy for as long as the GPU took. It now calls ProcessEvents,
checks for device loss, then sleeps on a condition variable until the
map callback lands or 1 ms passes.

The map state also carries a generation, so a callback from a request
that shutdown or a rotation already retired can no longer mark the
current buffer mapped or unmapped.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:35:51 +00:00
Claude c8220c0c11 Report graphics startup failures instead of aborting
When no backend could create a window and a WebGPU device, aurora hit
an ASSERT and the process aborted with only SDL's last error in the log.
Keep the first adapter, device or window error, return it from
aurora_initialize with a new initializationStatus, and have the runtime
throw it so the existing fatal popup and fatal log record the reason.

ImGui shutdown now skips backends that never initialized, so the
shutdown that follows a failed start does not crash.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:33:43 +00:00
Claude c91c1b289c Allow copying out of the EFB copy source snapshot
When a copy with clear snapshots its source, the plain-copy path reads
the snapshot with CopyTextureToTexture, but the snapshot texture was
created without CopySrc usage.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:31:09 +00:00
Claude a23fd087ee Fix native EFB readback blits on BGRA surfaces and their Y clamp
The shared blit shader clamps the sampled Y to flags.z..w, but the
native readback uniform set flags.w to 0, so every row sampled row 0.
The blit pipeline was also built only for the surface format, while the
readback draws into an RGBA8 texture; on a BGRA surface (the usual
Linux/Vulkan choice) the pipeline and attachment formats disagree, which
release builds no longer catch because Dawn validation is skipped.

Build blit pipelines for RGBA8, BGRA8 and the surface format, and pick
one by destination format. The Quest 1 simple blit path is unchanged.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:31:08 +00:00
Claude 4407013d61 Check TEV register packing by content instead of uniform size
The test expected one extra TEV register to grow the uniform by exactly
16 bytes, but the allocation is alignment-rounded, so it failed on the
fork's baseline. Build the uniform and check that the register and the
K color after it are packed next to each other instead.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:29:26 +00:00
Claude 20ef08b3b8 Decode the first GEN_MODE write even when it is all zeros
The BP register cache skips writes equal to the cached value, and the
cache starts at zero. A cold GEN_MODE write of zero (one TEV stage, no
culling, no channels or texgens) was therefore dropped, leaving the
decoded state at zero TEV stages until some other GEN_MODE value arrived.
Seed the cache's register ID byte so the first write always decodes.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:29:26 +00:00
Claude b361ecd54c Stop draws from merging or reading past their own vertices
Incomplete primitives no longer emit indices: quads drop a one- or
two-vertex tail and draw a three-vertex tail as a triangle, triangle
lists ignore leftover vertices, and fans or strips under three vertices
emit nothing. Draws with no complete primitive are skipped. Previously an
incomplete quad indexed vertices that belonged to the next merged draw,
or past the end of the buffer.

Merging now stops before the 16-bit index offset would wrap, never folds
triangles into a single-instance line or point draw, and breaks after
GXInvalidateVtxCache or a vertex-format switch, so the next draw uploads
fresh arrays and uses its own format's shader.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad batch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:29:17 +00:00
Claude c12b6b7d99 Treat pipeline cache rows with inconsistent sizes as misses
Dawn asks for a blob's size before allocating and copying it. A row whose
stored length, declared size or zstd frame size disagree, or whose
compression flag is unknown, made it allocate the declared size and copy
or decompress past the stored blob. Such rows are now logged and skipped.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad 70951022).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:17:48 +00:00
Claude 5bf1904ca8 Lock the video render mode against concurrent readers
VIConfigure writes the mode on the game side while the GX thread, the
frame worker and the window code read its size. The resize request is
made after the lock is released.

From upstream patchzyy/Wiicompiled 6f14bde (#244).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:17:48 +00:00
Claude a0f97d307d Read packed three-byte vertex attributes without touching the next word
load_u24 and raw_fetch_u8_3 read a whole 32-bit word pair even when the
three bytes sit in the first word, so an attribute at the end of its
binding read past it. Release builds disable Dawn's robustness, so that
read isn't clamped.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad 0f6b274).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:17:47 +00:00
Claude 491d39503e Wake every pipeline waiter when shader work is queued or promoted
Compiler workers and renderer waiters share g_pipelineCv. notify_one could
wake a renderer thread instead of a compiler, leaving a queued pipeline
until some later wake-up; with skip_unready_pipelines its draws stay
missing that much longer. Promoting a background build to priority now
wakes the workers too.

From upstream patchzyy/Wiicompiled 6f14bde (#244, KartPad 956d811e).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg7mB8ogCWmp9GH19Uc82B
2026-10-05 13:17:47 +00:00
mitch030504 a132a82952 Merge pull request #7 from mitch030504/claude/project-thread-ix2nko
Port upstream game fixes: Linux TLS, saves across drives, controllers, alarms
2026-10-05 15:15:42 +02:00
Claude cfb9623e83 Bundle the TLS root certificates in the Android app
The mbed TLS path looks for cacert.pem in the runtime resources folder on
Android, the same place as dsp_coef.bin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tcyLz63pXjoYEwsFjfg8F
2026-10-05 12:58:11 +00:00
Claude 35e045f9f7 Port the runtime and translator fixes from upstream's KartPad batch
From patchzyy/Wiicompiled 6f14bde (#244), runtime and translator parts only:
- Keep KD and NCD devices available while networking is off; only IP and
  SSL need the network.
- Run RFLiIsWorking's alarm pump in a private interrupt context so callback
  register writes don't clobber the caller, and release the alarm recursion
  guard before rescheduling.
- Share one LR continuation dispatch per translated function when it has
  several continuation calls.

The renderer half of that commit is left out: it conflicts with the stereo
renderer, and upstream reverted part of it (e409d9f) for stutters.

(cherry picked from part of commit 6f14bde26a4ed6390d4f18e1b3a9c1be6819d589)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tcyLz63pXjoYEwsFjfg8F
2026-10-05 12:58:10 +00:00
dorPXP 504ea792da Add TLS support for non-Windows devices (#144)
* Implement real TLS for non-Windows via vendored mbed TLS

Windows gets TLS for the guest network HLE's SSL ioctlvs for free from
Schannel; every other platform fell into a stub that always returned
failure, meaning any HTTPS-based network feature (WFC login, fetching
the Retro-WFC payload) silently could not work at all on those
platforms regardless of server availability.

Vendors mbed TLS 3.6.7 LTS under runtime/third_party/mbedtls (same
convention as Crypto++/pugixml - a real source checkout, not a
submodule/FetchContent download) and a standard Mozilla CA bundle
(runtime/assets/certs/cacert.pem, via curl.se's redistribution) copied
next to the built product the same way dsp_coef.bin already is.

Verified against real HTTPS servers: a valid certificate completes the
handshake and an HTTP round-trip; a known-expired certificate is
correctly rejected with a real X509 verification failure, not silently
accepted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qmdewk7VfVVJTfCVd2WStu

* Fix TLS handshake hang and partial-write truncation on non-Windows

Add a POSIX socket timeout to match Windows' existing 15s one, plus a
deadline on the handshake retry loop itself, so a peer that accepts the
TCP connection but never sends TLS data can no longer hang the thread
forever. Also fix SslWrite to loop on partial mbedTLS writes instead of
returning the first partial count, and add mbedTLS to
THIRD-PARTY-NOTICES.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fetch mbedTLS from a pinned, checksum-verified release instead of vendoring it

Replace the committed mbedTLS source tree with a CMake FetchContent download
of the official mbedtls-3.6.7 release tarball, verified against its signed
SHA-256, matching how aurora-main's own dependencies (SDL, zlib, etc.) are
pulled in. Ships the compiled dependency instead of ~280 tracked upstream
files. CA bundle packaging and THIRD-PARTY-NOTICES.md coverage are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Limit the mbedTLS dependency to the platforms that use it

The FetchContent block ran on every platform, including Windows, whose builds
configure with FETCHCONTENT_FULLY_DISCONNECTED=ON against the offline
dependency set from Launcher/Prepare-Dependencies.ps1 - which has no
mkw_mbedtls_upstream entry, so a clean Windows configure failed. Windows
compiles the Schannel path (network_ssl.cpp is `#ifndef _WIN32` for mbed TLS)
and never links mbed TLS, so nothing needs preparing there: the fetch, the
linkage and the cacert.pem copy are now guarded to non-Windows, while the
mkw::mbedtls alias stays defined everywhere so the link lines in
PublicProducts.cmake remain platform-independent.

Also copy cacert.pem alongside the installed executable in the Linux and macOS
publication paths (Launcher/local-build.sh and Launcher/macos/publish-app.command),
which already copied the other runtime assets but left the TLS root bundle in
the build directory, so published builds could not verify any certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Harden mbed TLS socket I/O handling

* delete wii socket

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
(cherry picked from commit b59e035b872752df8bfc637bba79689c12abf292)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tcyLz63pXjoYEwsFjfg8F
2026-10-05 12:56:13 +00:00
patchzyy f87732d0ad add bltl (#254)
(cherry picked from commit 583e702547d0347d4634d48a314fc8dd3de75db2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tcyLz63pXjoYEwsFjfg8F
2026-10-05 12:56:13 +00:00
Michael G 152c4f5f38 Fix controller mapping platform, player index sync, and socket errors (#251)
* Fix macOS controller platform, sync player indices, and preserve socket send error

- Preserve captured socket send error across diagnostic logging in network_socket.cpp
- Use SDL_GetPlatform() instead of hardcoded Windows in controller mapping wizard
- Synchronize Aurora and SDL player indices on port assignment and clear in pad.cpp
- Auto-assign Player 1 on macOS when unconfigured in input.cpp

* fix(input): refine macOS port 0 assignment and persistence logic

- Persist port 0 preference for newly connected controller even if already assigned player index 0
- Verify controller has no preference on other ports before assigning to port 0

(cherry picked from commit 85f250155f8f397c1c463dc51dd4dc2707371d06)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tcyLz63pXjoYEwsFjfg8F
2026-10-05 12:56:12 +00:00
Daan Vervacke 794b715d31 [Linux] Handle NAND moves across mount points (#212)
* Fix NAND moves across mount points

* Harden cross-mount NAND move fallback

* Preserve directory copy on NAND move cleanup failure

* Clarify NAND move cleanup behavior

* Use exclusive scratch paths for NAND moves

* Copy NAND move directories into reserved destination

* Publish NAND move directories without replacement

(cherry picked from commit 83463764b8acda394e058b0c689a10b8561fc380)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tcyLz63pXjoYEwsFjfg8F
2026-10-05 12:56:12 +00:00
mitch030504 a532ffa6f3 Merge pull request #6 from mitch030504/claude/project-thread-asqg38
Port Retro Rewind install, adaptive resolution and a scene-load crash fix from VR-PLUS
2026-10-05 14:45:27 +02:00
79 changed files with 6662 additions and 578 deletions

No files matched your search

+18 -1
View File
@@ -2,7 +2,7 @@ name: Build
on:
push:
branches: [main]
branches: [main, openxr-work]
pull_request:
workflow_dispatch:
@@ -46,3 +46,20 @@ jobs:
- name: Test
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
linux-setup:
name: Linux setup and Frame installer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.x'
- name: Test disc staging, validation and cancellation
run: dotnet test translator/Translator.sln -c Release --filter FullyQualifiedName~LinuxDiscExtractionTests
- name: Test Frame installation and update file operations
run: python3 -m unittest discover -s Launcher/tests -v
- name: Check installer shell syntax
run: bash -n Launcher/steam-frame-install.sh Launcher/local-build.sh
+1 -1
View File
@@ -12,7 +12,7 @@ on:
tag:
description: Release tag, frame-<something>; docs/releases/<tag>.md holds its notes
required: true
default: frame-beta-2
default: frame-beta-4
permissions:
contents: write
jobs:
+3
View File
@@ -84,3 +84,6 @@ output.txt
/android/app/.cxx/
/android/app/libs/*.aar
/android/.runs/
# Python regression checks
__pycache__/
+11 -1
View File
@@ -39,9 +39,19 @@ That's ok, but rules apply:
game (hardware comparison, logs, whatever fits).
- Review feedback. It's about the code, not about you ;).
## Porting from other projects
This fork takes fixes from upstream WiiCompiled and other forks. When you port one:
- name the source repository and commit in the commit message ("Ported from owner/repo abc1234");
- keep the original author on the commit when the change is taken as is, and say what you changed
when it is adapted;
- add a line to [`CREDITS.md`](CREDITS.md), and check the source's license is compatible with
GPL v3.0.
## Bug reports
See the FAQ in the [README](README.md)
See [Reporting problems](README.md#reporting-problems) in the README.
## A note on related projects
+79
View File
@@ -0,0 +1,79 @@
# Credits
WiiCompiled VR for the Steam Frame is the work of many people. This fork adds the SteamOS build,
the install and diagnostics scripts and the Frame-specific rendering, but most of what you play
was written elsewhere, and a good part of this fork's own fixes were ported from other people's
projects. This page lists where everything came from.
Every project below is GPL-3.0 licensed like this one unless it says otherwise. Ported commits name
their source repository and commit in their messages, and keep the original author where the
change was taken as is, so `git log` is the full record. Bundled libraries and their licenses are
in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
## The projects this fork is built on
- **[WiiCompiled](https://github.com/patchzyy/Wiicompiled)** by patchzyy and its contributors: the
static recompilation of Mario Kart Wii itself, the translator, the runtime and the Aurora-based
renderer.
- **[WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR)** by iChris4: the OpenXR VR
renderer, the Quest app, the headset settings panel, foveation and the first-person cockpit. This
fork contains all of its `openxr-work` branch up to 2026-10-01.
- **[mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port)** by heurazy: the
cockpit's turning steering wheel, hand steering and USB wheel support, which came in through
WiiCompiled OpenXR VR.
- **[DolphinXR](https://github.com/iChris4/dolphinXR)**: the OpenXR Wii Remote input design and its
default button profile, which the controller code (`runtime/src/vr/openxr_input.cpp`) and the
Steam Frame controller profile's input paths are adapted from.
## Ported into this fork
### From WiiCompiled (upstream)
Cherry-picked or adapted from [patchzyy/Wiicompiled](https://github.com/patchzyy/Wiicompiled) after
this fork's line split from it:
| Change | Author | Upstream |
| --- | --- | --- |
| TLS through mbed TLS on Linux and Android, which online play (Retro Rewind WFC) needs there | dorPXP, with patchzyy | #144 |
| NAND moves across mount points on Linux (saves on a different drive or the SD card) | Daan Vervacke | #212 |
| Controller mapping platform, player index sync, and socket send errors | Michael G ([DarthMDev](https://github.com/DarthMDev)) | #251 |
| `bltl` translation | patchzyy | #254 |
| The KartPad batch: KD and NCD kept while offline, RFL alarm interrupt context, shared LR continuation dispatch, pipeline cache size checks, pipeline waiter wake-ups, packed three-byte vertex reads, draw merging bounds, GEN_MODE decode, EFB copy and readback fixes, graphics startup errors, and the staging buffer wait | patchzyy, adapting Chris Sotraidis's [KartPad](https://github.com/chrissotraidis/wiicompiled) fixes | #244 |
### From other WiiCompiled forks
| Change | Author | Source |
| --- | --- | --- |
| Scene-load crash fix (`GXClearVtxDesc` keeps the position descriptor) | heurazy | [Wiicompiled_VR-PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS) c2b2500 |
| `[vr] adaptive_resolution` | heurazy | Wiicompiled_VR-PLUS, adapted |
| Building and installing Retro Rewind from its own update server (`steam-frame-install.sh --retro-rewind`) | heurazy | Wiicompiled_VR-PLUS, adapted |
| Faster disc reads: bulk DVD DMA copies, Yaz0 (SZS) decoding through host buffers, per-thread disc file handles | Chris Sotraidis | [KartPad](https://github.com/chrissotraidis/wiicompiled) ec14a0d, 97cdc9a, affabbd |
| No debug labels in the ImGui passes in normal builds (an Adreno driver crash) | Chris Sotraidis | KartPad 420b828, extended to this fork's extra passes |
| A TLS session that failed a write is kept instead of its socket being deleted | Michael G ([DarthMDev](https://github.com/DarthMDev)) | [DarthMDev/Wiicompiled](https://github.com/DarthMDev/Wiicompiled) a2ecc0f, fcf8646 (upstream PR #258) |
| `Config.toml` saved through a temporary file and a rename | BlackAndBlue95 | [Strikers-WiiCompiled](https://github.com/BlackAndBlue95/Strikers-WiiCompiled) 435daf3 |
| No depth writes while the Z compare is off (the Stormship lightning) | BlackAndBlue95 | Strikers-WiiCompiled 369ec99 |
| NEON forms of the AX audio mix kernels | rooklz | [rooklz/Wiicompiled](https://github.com/rooklz/Wiicompiled) 4346362 |
| Emptied WC24 bootstrap files are refilled | nx-mod | [wiicompiled-nx](https://github.com/nx-mod/wiicompiled-nx) d0149e6, adapted |
| Guest memory access checks folded away on 4 KiB-page builds | nx-mod's finding | wiicompiled-nx 18ef16f, done here as a compile-time constant |
## Tools and references
- **[aurora](https://github.com/encounter/aurora)** by Luke Street: the GX rendering and windowing
backend the whole graphics layer sits on (MIT).
- **[Dawn](https://dawn.googlesource.com/dawn)**: Google's WebGPU implementation, under Aurora.
- **[OpenXR](https://www.khronos.org/openxr/)** by Khronos: the VR API.
- **[Mbed TLS](https://github.com/Mbed-TLS/mbedtls)**: TLS for online play on Linux and Android.
- **[Dolphin Emulator](https://github.com/dolphin-emu/dolphin)**: a reference for Wii hardware
behaviour, and the source of the DSP coefficient ROM, the WiiConnect24 bootstrap tree and the
Riivolution parser bundled with the runtime.
- **[nod](https://github.com/encounter/nod)** by Luke Street: `nodtool`, the disc image extractor.
- **[Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind)** by ZPL and team: the mod
distribution this project supports.
- **[Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard)**: the mod manager the PC build
integrates with, and the model for how the install script fetches Retro Rewind.
- **[Frame Control](https://github.com/saphid/frame-control)** by saphid: installing the game into
the Frame's Steam library, and its notes on how the Frame's software fits together.
- **Inkwreck**: the logo.
- Everyone in the static recompilation community.
If something of yours is in this fork and missing here, or credited wrongly, please open an issue.
+4
View File
@@ -1,5 +1,9 @@
# Windows VR distribution
This page covers upstream's Windows releases, kept for the PC build. The Steam Frame releases
(`frame-*` tags) are source only and are published by `.github/workflows/frame-release.yml` with
the notes in `docs/releases/<tag>.md`; see the [README](README.md#quick-start).
Distribute only `WiiCompiled-Setup.exe` and its checksum from
[iChris4/Wiicompiled_VR](https://github.com/iChris4/Wiicompiled_VR/releases), and
`WheelWizardVRWindows.exe` from [iChris4/WheelWizard_VR](https://github.com/iChris4/WheelWizard_VR/releases).
+78 -35
View File
@@ -30,37 +30,58 @@ internal static class DiscTool
"Only your own legally-owned copy of that exact game/region can be used.");
}
// Extracted straight into Assets/DATA (kept, not a scratch dir) - the runtime reads course/
// texture/audio data from this directory live via [paths] dvd_root, not just at translation
// time, so it has to survive past this install (see Program.cs, which points dvd_root here).
reporter.Progress(InstallStages.ExtractDisc, "Extracting the disc image", 4);
var dataDir = Path.Combine(assetsDirectory, "DATA");
if (Directory.Exists(dataDir)) Directory.Delete(dataDir, recursive: true);
await RunExtractAsync(nodTool, isoPath, dataDir, cancellationToken);
var dolPath = Path.Combine(dataDir, "sys", "main.dol");
var relPath = Path.Combine(dataDir, "files", "rel", "StaticR.rel");
if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath);
if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath);
var dolSha = Sha256Of(dolPath);
var relSha = Sha256Of(relPath);
if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
Directory.CreateDirectory(assetsDirectory);
File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true);
File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true);
var dataDir = Path.Combine(assetsDirectory, "DATA");
var backup = dataDir + ".replaced";
if (!Directory.Exists(dataDir) && Directory.Exists(backup)) Directory.Move(backup, dataDir);
if (Directory.Exists(backup)) Directory.Delete(backup, recursive: true);
var staging = dataDir + ".extracting";
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
try
{
await RunExtractAsync(nodTool, isoPath, staging, cancellationToken);
var dolPath = Path.Combine(staging, "sys", "main.dol");
var relPath = Path.Combine(staging, "files", "rel", "StaticR.rel");
if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath);
if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath);
var dolSha = Sha256Of(dolPath);
var relSha = Sha256Of(relPath);
if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
cancellationToken.ThrowIfCancellationRequested();
if (Directory.Exists(dataDir)) Directory.Move(dataDir, backup);
try
{
Directory.Move(staging, dataDir);
}
catch
{
if (Directory.Exists(backup)) Directory.Move(backup, dataDir);
throw;
}
FileSystemUtilities.WriteAtomic(Path.Combine(assetsDirectory, "main.dol"),
File.ReadAllBytes(Path.Combine(dataDir, "sys", "main.dol")));
FileSystemUtilities.WriteAtomic(Path.Combine(assetsDirectory, "StaticR.rel"),
File.ReadAllBytes(Path.Combine(dataDir, "files", "rel", "StaticR.rel")));
}
finally
{
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
if (Directory.Exists(backup)) Directory.Delete(backup, recursive: true);
reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6);
}
@@ -75,9 +96,20 @@ internal static class DiscTool
};
using var process = System.Diagnostics.Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {nodTool}.");
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
throw;
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
@@ -104,9 +136,20 @@ internal static class DiscTool
using var process = System.Diagnostics.Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {nodTool}.");
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
throw;
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
@@ -15,6 +15,7 @@
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Translator.Tests" />
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
</ItemGroup>
</Project>
+7 -1
View File
@@ -244,6 +244,10 @@ else
translator() { "$dotnet_bin" "$translator_dll" "$@"; }
fi
if (( builds_retro && !skip_retro_wfc_payload )); then
translator validate-retro-wfc-payload --directory "$retro_wfc_offline_dir"
fi
# ---------------------------------------------------------------------------
# Parallelism: three independent knobs, same reasoning as LocalBuild.ps1 -
# translator_threads (translation's own worker threads), translated_jobs (the real RAM guard,
@@ -503,7 +507,9 @@ publish_built_product() {
local exe=$build/$target
assert_file "$exe" "Locally compiled game executable"
cp -f "$exe" "$destination/$target"
for name in dsp_coef.bin initial_pipeline_cache.db; do
# cacert.pem is the TLS root bundle the mbed TLS path looks up beside the executable
# (runtime/src/hle/net/network_ssl.cpp); without it HTTPS fails at runtime.
for name in dsp_coef.bin initial_pipeline_cache.db cacert.pem; do
[[ -f "$build/$name" ]] && cp -f "$build/$name" "$destination/"
done
[[ -d "$build/wii_bootstrap" ]] && cp -rf "$build/wii_bootstrap" "$destination/"
+2 -2
View File
@@ -27,7 +27,7 @@ done
[[ "$product" == WiiCompiled || "$product" == RetroRewind ]] || fail '--product must be WiiCompiled or RetroRewind'
for tool in codesign ditto install_name_tool otool; do command -v "$tool" >/dev/null || fail "required macOS tool is unavailable: $tool"; done
[[ -x "$build_dir/$product" ]] || fail "missing compiled product: $build_dir/$product"
for asset in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap; do [[ -e "$build_dir/$asset" ]] || fail "missing runtime asset: $build_dir/$asset"; done
for asset in dsp_coef.bin initial_pipeline_cache.db cacert.pem wii_bootstrap; do [[ -e "$build_dir/$asset" ]] || fail "missing runtime asset: $build_dir/$asset"; done
app="$output_dir/$product.app"
macos="$app/Contents/MacOS"
@@ -52,7 +52,7 @@ cat > "$app/Contents/Info.plist" <<EOF
</dict></plist>
EOF
ditto "$build_dir/$product" "$macos/$product"
for asset in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap; do
for asset in dsp_coef.bin initial_pipeline_cache.db cacert.pem wii_bootstrap; do
ditto "$build_dir/$asset" "$resources/$asset"
ln -s "../Resources/$asset" "$macos/$asset"
done
+394 -67
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash
# Builds WiiCompiled VR for the Steam Frame from a release and your own disc, and installs it on the
# Frame. Run it again to update: it fetches the newest release and rebuilds only what changed.
# Frame. Run it again with --update to update: it fetches the newest release and rebuilds only what
# changed, with the options it was installed with.
#
# Launcher/steam-frame-install.sh --disc PATH [--frame HOST] [options]
# Launcher/steam-frame-install.sh --update
#
# Or without downloading anything first:
#
@@ -36,32 +38,89 @@
# --retro-rewind-pack DIR
# use this RetroRewind6 folder instead of downloading one (implies
# --retro-rewind); it is copied into the work dir and never changed
# --update update an earlier install: its options are reused (any given here win), and
# nothing is built when the Frame already has the newest release and Retro
# Rewind pack. Follows releases unless it was installed with --source.
# --check only say whether an update is available; nothing is changed
# -h, --help
#
# Installed with --frame local, the game can update itself: Settings > Updates in the game checks
# for a new release, and its Update button runs this script with --update in the background,
# through a systemd user service this script sets up. The tab shows the update's progress while the
# game stays open, and a game closed meanwhile is opened again when the update is done.
set -euo pipefail
# Everything is in main, so bash has read the whole script before running any of it: when it comes
# through curl | bash, a step that reads stdin cannot eat the rest. (Not indented, for the heredocs.)
main() {
trap 'printf "\nsteam-frame-install.sh: stopped by a failed step (line %s); the output above says why.\n" "$LINENO" >&2' ERR
# What the error path below reads, set before anything can fail into it.
mode=install
steam_game_id=""
trap 'on_error "$LINENO"' ERR
repo=mitch030504/Wiicompiled_VR_Frame
game_id=WiiCompiled
image=docker.io/library/debian:trixie
container=wiicompiled-frame-build
nodtool_version=v2.0.0-alpha.10
# Where the game keeps its settings, and where an update it starts reports back to it. Resolved as
# the game resolves it (RuntimeConfigFile::ApplicationDataDirectory).
game_data="${XDG_DATA_HOME:-$HOME/.local/share}/$game_id"
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
say() {
printf '\n\033[1m==> %s\033[0m\n' "$*"
status running "$*"
}
note() { printf ' %s\n' "$*"; }
fail() {
printf '\nsteam-frame-install.sh: error: %s\n' "$*" >&2
finish_failed "${1%%$'\n'*}"
}
on_error() {
printf '\nsteam-frame-install.sh: stopped by a failed step (line %s); the output above says why.\n' "$1" >&2
finish_failed "a step failed (line $1)"
}
finish_failed() {
trap - ERR
status failed "$1"
relaunch
exit 1
}
usage() { awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$script_path"; }
# The game's Updates tab reads this: "<state>\t<unix time>\t<text>", state running, available,
# uptodate, done or failed. Written only when the game asked for this run
# (WIICOMPILED_UPDATE_STATUS names the file).
status() {
local file=${WIICOMPILED_UPDATE_STATUS:-}
[[ -n "$file" ]] || return 0
{ printf '%s\t%s\t%s\n' "$1" "$(date +%s)" "$2" > "$file.new" && mv -f "$file.new" "$file"; } 2>/dev/null || true
}
# Progress goes to that file rather than to Steam notifications: on the Frame, SteamOS's
# steam_notif_daemon accepts a notify-send and Steam then shows nothing, even when given the
# notification directly with steam -ifrunning (tried on a Frame, 2026-10).
# An update the game started ends by opening the game again if it was closed meanwhile, which is
# how its result gets seen; a game still open shows the result in its Updates tab.
game_running() { pgrep -f "devkit-game/($game_id|RetroRewind)/" >/dev/null 2>&1; }
relaunch() {
if [[ -n "${install_lock:-}" ]]; then
flock -u "$install_lock"
exec {install_lock}>&-
unset install_lock
fi
[[ -n "$steam_game_id" ]] || return 0
command -v steam >/dev/null 2>&1 || return 0
if game_running; then return 0; fi
note "opening $game_id again"
timeout 60 steam "steam://rungameid/$steam_game_id" >/dev/null 2>&1 || true
}
script_path=${BASH_SOURCE[0]:-}
script_dir=""
if [[ -n "$script_path" && -f "$script_path" ]]; then
script_dir=$(cd "$(dirname "$script_path")" && pwd)
script_path="$script_dir/$(basename "$script_path")"
fi
disc=""
@@ -85,15 +144,253 @@ while [[ $# -gt 0 ]]; do
--frame-disc) frame_disc=$2; shift 2 ;;
--retro-rewind) retro_rewind=1; shift ;;
--retro-rewind-pack) retro_rewind=1; retro_pack=$2; shift 2 ;;
--update) mode=update; shift ;;
--check) mode=check; shift ;;
-h|--help)
if [[ -n "$script_dir" ]]; then usage; else echo "See the comment at the top of the script."; fi
exit 0 ;;
*) fail "unknown argument: $1 (see --help)" ;;
esac
done
# What --update reuses: the options given, before defaults fill the rest in.
jobs_given=$jobs
source_given=$source_dir
mkdir -p "$work_dir"
work_dir=$(cd "$work_dir" && pwd)
command -v flock >/dev/null || fail "flock is required (install util-linux)."
exec {install_lock}>"$work_dir/.install.lock"
flock -n "$install_lock" || fail "Another install or update is using $work_dir. Wait for it to finish."
command -v python3 >/dev/null || fail "Python 3 is required for installation."
# The game's Update button leaves a request file; the service that runs this script moves it here.
if [[ -n "${WIICOMPILED_UPDATE_REQUEST:-}" && -f "$WIICOMPILED_UPDATE_REQUEST" ]]; then
steam_game_id=$(sed -n 's/^steam_game_id=\([0-9]*\)$/\1/p' "$WIICOMPILED_UPDATE_REQUEST" | head -n 1)
rm -f "$WIICOMPILED_UPDATE_REQUEST"
fi
if [[ "$mode" != install && -f "$work_dir/install.conf" ]]; then
while IFS='=' read -r key value; do
case "$key" in
frame) [[ -n "$frame" ]] || frame=$value ;;
frame_disc) [[ -n "$frame_disc" ]] || frame_disc=$value ;;
jobs) [[ -n "$jobs" ]] || jobs=$value ;;
source) [[ -n "$source_dir" ]] || source_dir=$value ;;
retro_rewind) if [[ "$value" == 1 ]]; then retro_rewind=1; fi ;;
retro_rewind_pack) [[ -n "$retro_pack" ]] || retro_pack=$value ;;
esac
done < "$work_dir/install.conf"
jobs_given=$jobs
source_given=$source_dir
fi
frame_files() {
python3 - "$@" <<'FRAME_FILES_PY'
import filecmp
import json
import os
from pathlib import Path
import re
import shutil
import sys
import tempfile
def atomic_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=path.name + '.', dir=path.parent)
try:
with os.fdopen(descriptor, 'w', encoding='utf-8') as output:
output.write(text)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def set_path(key, value):
if key not in ('dvd_root', 'retro_rewind_root'):
raise ValueError('Unknown runtime path key')
config = Path.home() / '.local/share/WiiCompiled/Config.toml'
value = os.path.expanduser(value)
if not os.path.isabs(value):
value = str(Path.home() / value)
lines = config.read_text(encoding='utf-8').splitlines(keepends=True) if config.exists() else []
# JSON strings use the escaping needed by TOML basic strings for filesystem paths.
setting = key + ' = ' + json.dumps(value, ensure_ascii=False) + '\n'
start = next((i for i, line in enumerate(lines) if re.fullmatch(r"\s*\[\s*[\"']?paths[\"']?\s*\]\s*(?:#.*)?", line.strip())), None)
if start is None:
lines += ['\n[paths]\n', setting]
else:
end = next((i for i in range(start + 1, len(lines)) if lines[i].lstrip().startswith('[')), len(lines))
matches = [i for i in range(start + 1, end) if re.match(r"\s*[\"']?" + key + r"[\"']?\s*=", lines[i])]
for i in reversed(matches):
del lines[i]
lines.insert(start + 1, setting)
atomic_write(config, ''.join(line if line.endswith('\n') else line + '\n' for line in lines))
def sync_source(source, destination):
source, destination = Path(source), Path(destination)
manifest = destination / '.release-files'
old = manifest.read_text(encoding='utf-8').splitlines() if manifest.exists() else []
current = sorted(str(path.relative_to(source)) for path in source.rglob('*') if path.is_file() or path.is_symlink())
current_set = set(current)
for name in old:
relative = Path(name)
if relative.is_absolute() or '..' in relative.parts or not relative.parts:
raise ValueError('Invalid release-owned path: ' + name)
if name not in current_set:
target = destination / relative
if target.is_symlink() or target.is_file():
target.unlink()
for name in current:
incoming, target = source / name, destination / name
target.parent.mkdir(parents=True, exist_ok=True)
if incoming.is_symlink():
if target.is_symlink() and os.readlink(target) == os.readlink(incoming):
continue
if target.exists() or target.is_symlink():
target.unlink()
target.symlink_to(os.readlink(incoming))
elif target.is_symlink() or not target.is_file() or not filecmp.cmp(incoming, target, shallow=False):
if target.is_symlink():
target.unlink()
shutil.copyfile(incoming, target)
shutil.copymode(incoming, target)
else:
shutil.copymode(incoming, target)
atomic_write(manifest, '\n'.join(current) + '\n')
if __name__ == '__main__':
if sys.argv[1] == 'config':
set_path(*sys.argv[2:])
elif sys.argv[1] == 'sync':
sync_source(*sys.argv[2:])
else:
raise ValueError('Unknown file operation')
FRAME_FILES_PY
}
save_settings() {
cat > "$work_dir/install.conf" <<EOF
# Written by steam-frame-install.sh after each install: the options --update reuses.
frame=$frame
frame_disc=$frame_disc
jobs=$jobs_given
source=$source_given
retro_rewind=$retro_rewind
retro_rewind_pack=$retro_pack
EOF
}
# ---------------------------------------------------------------------------------------------
# Talking to the Frame, and the versions involved. Up here because --update and --check read them
# before anything is built.
# One SSH connection for every step, so a password is asked for once.
ssh_opts=(-o ControlMaster=auto -o "ControlPath=${XDG_RUNTIME_DIR:-/tmp}/wiicompiled-ssh-%C" -o ControlPersist=600)
on_frame() {
# Runs a bash script, given on stdin, on the Frame; its arguments follow.
if [[ "$frame" == local ]]; then
bash -s -- "$@"
else
local arguments
printf -v arguments ' %q' "$@"
ssh "${ssh_opts[@]}" "$frame" "bash -s --$arguments"
fi
}
read_on_frame() {
# Prints the contents of $1 on the Frame, with its whitespace removed, or nothing.
on_frame "$1" <<'EOF'
[[ "$1" = /* ]] && f=$1 || f="$HOME/$1"
tr -d '[:space:]' 2>/dev/null < "$f" || true
EOF
}
frame_game_dir="devkit-game/$game_id"
newest_release() {
curl -fsSL "https://api.github.com/repos/$repo/releases?per_page=1" |
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1
}
# Retro Rewind's own versioning, as its update server publishes it.
rr_server=https://update.rwfc.net/RetroRewind/
rr_version_ok() { [[ "$1" =~ ^[0-9]+(\.[0-9]+)+$ ]]; }
rr_newer() {
# True when version $1 is newer than $2 (dotted numbers, as 6.12.7).
[[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]
}
rr_fetch() { curl -fsSL --retry 2 "$1"; }
rr_published_version() {
rr_fetch "${rr_server}RetroRewindVersion.txt" |
awk 'NF >= 4 && $1 ~ /^[0-9]+(\.[0-9]+)+$/ { print $1 }' | sort -V | tail -n 1
}
# Where the pack sits on the Frame: beside the disc, as the install below puts it.
frame_pack_path_for() {
local disc=${1#\~/}
[[ -n "$disc" ]] || disc=wiicompiled/disc
if [[ "$disc" == */* ]]; then printf '%s/RetroRewind6\n' "$(dirname "$disc")"; else printf 'RetroRewind6\n'; fi
}
# ---------------------------------------------------------------------------------------------
# --update and --check: what the Frame has against what is published. Nothing is built when the
# Frame is already current, so an update that has nothing to do costs one web request.
if [[ "$mode" != install ]]; then
[[ -n "$frame" ]] || fail "--$mode updates an install made by this script, and there is none in
$work_dir. Install first (see --help), or pass --work-dir and --frame."
say "Checking for an update"
reasons=()
if [[ -n "$source_dir" ]]; then
# A source install has no published version to compare against; it rebuilds what changed.
reasons+=("it was installed from the source tree in $source_dir, which is rebuilt as it is")
else
if [[ -z "$release" ]]; then
release=$(newest_release) ||
fail "could not reach github.com to ask for the newest release. Check this machine's
internet connection, then try again."
[[ -n "$release" ]] || fail "could not find the newest release on github.com/$repo"
fi
frame_installed_release=$(read_on_frame "$frame_game_dir/.release-tag") ||
fail "cannot reach $frame over SSH to ask which release it has."
if [[ "$frame_installed_release" == "$release" ]]; then
note "the Frame has release $release"
elif [[ -z "$frame_installed_release" ]]; then
reasons+=("the Frame does not say which release it has; release $release would be installed")
else
reasons+=("release $release (the Frame has $frame_installed_release)")
fi
fi
if (( retro_rewind )) && [[ -z "$retro_pack" ]]; then
rr_published=$(rr_published_version) ||
fail "could not reach Retro Rewind's update server (${rr_server})"
rr_installed=$(read_on_frame "$(frame_pack_path_for "$frame_disc")/version.txt")
if ! rr_version_ok "$rr_installed"; then
reasons+=("the Frame does not say which Retro Rewind pack it has")
elif rr_newer "$rr_published" "$rr_installed"; then
reasons+=("Retro Rewind $rr_published (the Frame has $rr_installed)")
else
note "the Frame has Retro Rewind $rr_installed"
fi
fi
if (( ${#reasons[@]} == 0 )); then
say "Up to date"
status uptodate "Up to date${release:+ (}${release}${release:+)}"
relaunch
exit 0
fi
note "an update is available:"
for line in "${reasons[@]}"; do note " $line"; done
if [[ "$mode" == check ]]; then
status available "${reasons[0]}"
exit 0
fi
status running "Starting the update${release:+ to }$release"
fi
host_arch=$(uname -m)
case "$host_arch" in
x86_64|aarch64) ;;
@@ -174,20 +471,14 @@ else
curl -fL --progress-bar "https://github.com/$repo/archive/refs/tags/$release.tar.gz" |
tar -xz --strip-components=1 -C "$work_dir/source-new" ||
fail "could not download release $release; the Releases page on github.com/$repo lists them"
# Only files whose content changed are copied, stamped with the current time, so the build
# recompiles exactly those; the release's own file dates can be older than the last build.
if command -v rsync >/dev/null 2>&1; then
rsync -rcE "$work_dir/source-new/" "$source_dir/"
else
(cd "$work_dir/source-new" && find . -type f -print0) |
while IFS= read -r -d '' file; do
if ! cmp -s "$work_dir/source-new/$file" "$source_dir/$file"; then
mkdir -p "$source_dir/$(dirname "$file")"
cp "$work_dir/source-new/$file" "$source_dir/$file"
touch "$source_dir/$file"
fi
done
# Old installs have no ownership manifest; recover it from their exact release.
if [[ ! -f "$source_dir/.release-files" && -n "$have" ]]; then
curl -fL --progress-bar "https://github.com/$repo/archive/refs/tags/$have.tar.gz" |
tar -tz | sed -e 's@^[^/]*/@@' -e '/\/$/d' -e '/^$/d' > "$source_dir/.release-files.partial" ||
fail "could not recover the previous release's file list; keeping the source untouched"
mv "$source_dir/.release-files.partial" "$source_dir/.release-files"
fi
frame_files sync "$work_dir/source-new" "$source_dir"
rm -rf "$work_dir/source-new"
printf '%s\n' "$release" > "$source_dir/.release-tag"
fi
@@ -344,16 +635,10 @@ fi
# ---------------------------------------------------------------------------------------------
# Retro Rewind: its pack, as its update server publishes it, and the Retro-WFC payload its online
# play runs. Same steps as the Quest app (android/.../RetroRewindPack.kt and GameBuild.kt).
rr_server=https://update.rwfc.net/RetroRewind/
# Its server, versions and comparisons are up with the other version helpers.
rr_pack="$work_dir/RetroRewind6"
rr_payload="$work_dir/retro-wfc/binary/payload.RMCPD00.bin"
rr_version_ok() { [[ "$1" =~ ^[0-9]+(\.[0-9]+)+$ ]]; }
rr_newer() {
# True when version $1 is newer than $2 (dotted numbers, as 6.12.7).
[[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]
}
rr_fetch() { curl -fsSL --retry 2 "$1"; }
rr_unpack_update() {
# rr_unpack_update URL DEST: downloads a published zip and lays its RetroRewind6/ tree over DEST.
# Only that tree is kept; the Riivolution XML beside it belongs to a Wii setup.
@@ -499,8 +784,31 @@ fi
"$runtime" start "$container" >/dev/null
note "container $container (mounts ${want_mounts//;/ })"
build_progress() {
# Passes the build's output on, and keeps the game's status file at the stage it is on and how
# far through it ninja is, from the "[done/total]" lines it prints.
local line stage=Building percent last=-1
while IFS= read -r line; do
printf '%s\n' "$line"
[[ -n "${WIICOMPILED_UPDATE_STATUS:-}" ]] || continue
if [[ "$line" == "== "* ]]; then
stage=${line#== }
stage=${stage%% (*}
last=-1
status running "$stage"
elif [[ "$line" =~ ^\[([0-9]+)/([0-9]+)\] ]] && (( BASH_REMATCH[2] > 0 )); then
percent=$(( BASH_REMATCH[1] * 100 / BASH_REMATCH[2] ))
if (( percent != last )); then
last=$percent
status running "$stage, $percent% built"
fi
fi
done
}
say "Building (the first time takes hours under emulation; a log is in $work_dir/build.log)"
if ! "$runtime" exec -e JOBS="$jobs" -e RETRO_REWIND="$retro_rewind" "$container" bash /work/container-build.sh 2>&1 | tee "$work_dir/build.log"; then
if ! "$runtime" exec -e JOBS="$jobs" -e RETRO_REWIND="$retro_rewind" "$container" bash /work/container-build.sh 2>&1 |
tee "$work_dir/build.log" | build_progress; then
fail "the build stopped; the end of $work_dir/build.log says why. Run the script again to resume.
A machine that froze ran out of memory: pass a lower --jobs."
fi
@@ -520,12 +828,7 @@ if [[ -z "$frame" ]]; then
fi
say "Installing on the Frame"
# One SSH connection for every step, so a password is asked for once.
ssh_opts=(-o ControlMaster=auto -o "ControlPath=${XDG_RUNTIME_DIR:-/tmp}/wiicompiled-ssh-%C" -o ControlPersist=600)
on_frame() {
# Runs a bash script, given on stdin, on the Frame; its arguments follow.
if [[ "$frame" == local ]]; then bash -s -- "$@"; else ssh "${ssh_opts[@]}" "$frame" bash -s -- "$@"; fi
}
# on_frame and the SSH options it uses are up with the version helpers.
copy_to_frame() {
# copy_to_frame SOURCE DEST: DEST is absolute or relative to the Frame's home. A DEST ending in
# / is an existing folder SOURCE goes into; otherwise SOURCE is copied to that new name.
@@ -563,6 +866,11 @@ EOF
copy_to_frame "$out/$id" "$dir/$id.new"
on_frame "$dir" "$id" <<'EOF'
cd "$HOME/$1" && mv -f "$2.new" "$2" && chmod -R u=rwX,go=rX . && chmod 755 "$2"
EOF
# Which release is on the Frame, for --check and the game's Updates tab. A source build has no
# release to name, and the stamp is removed so neither claims a release this is not.
on_frame "$dir" "$release" <<'EOF'
cd "$HOME/$1" && if [[ -n "$2" ]]; then printf '%s\n' "$2" > .release-tag; else rm -f .release-tag; fi
EOF
}
install_game "$game_id" "$work_dir/out"
@@ -594,13 +902,8 @@ fi
if (( retro_rewind )); then
# The pack goes beside the disc; copied again only when its version changed.
frame_pack_path="$(dirname "$frame_disc_path")/RetroRewind6"
[[ "$frame_disc_path" == */* ]] || frame_pack_path=RetroRewind6
frame_pack_version=$(on_frame "$frame_pack_path" <<'EOF'
[[ "$1" = /* ]] && d=$1 || d="$HOME/$1"
tr -d '[:space:]' 2>/dev/null < "$d/version.txt" || true
EOF
)
frame_pack_path=$(frame_pack_path_for "$frame_disc_path")
frame_pack_version=$(read_on_frame "$frame_pack_path/version.txt")
local_pack_version=$(tr -d '[:space:]' 2>/dev/null < "$rr_pack/version.txt" || true)
if [[ -z "$frame_pack_version" || "$frame_pack_version" != "$local_pack_version" ]]; then
note "copying the Retro Rewind pack to $frame_pack_path (about 4 GB)"
@@ -614,38 +917,18 @@ EOF
rm -rf "$d" && mv "$d.partial" "$d"
EOF
fi
note "pointing Retro Rewind at its pack"
on_frame "$frame_pack_path" <<'EOF'
[[ "$1" = /* ]] && d=$1 || d="$HOME/$1"
config="$HOME/.local/share/WiiCompiled/Config.toml"
mkdir -p "$(dirname "$config")"
if [[ ! -f "$config" ]]; then
printf '[paths]\nretro_rewind_root = "%s"\n' "$d" > "$config"
elif ! grep -q '^retro_rewind_root *=' "$config"; then
if grep -q '^\[paths\]' "$config"; then
sed -i "/^\[paths\]/a retro_rewind_root = \"$d\"" "$config"
else
printf '\n[paths]\nretro_rewind_root = "%s"\n' "$d" >> "$config"
fi
fi
EOF
fi
note "pointing the game at the disc"
on_frame "$frame_disc_path" <<'EOF'
[[ "$1" = /* ]] && d=$1 || d="$HOME/$1"
config="$HOME/.local/share/WiiCompiled/Config.toml"
mkdir -p "$(dirname "$config")"
if [[ ! -f "$config" ]]; then
printf '[paths]\ndvd_root = "%s"\n' "$d" > "$config"
elif ! grep -q '^dvd_root *=' "$config"; then
if grep -q '^\[paths\]' "$config"; then
sed -i "/^\[paths\]/a dvd_root = \"$d\"" "$config"
else
printf '\n[paths]\ndvd_root = "%s"\n' "$d" >> "$config"
fi
set_frame_path() {
{ declare -f frame_files; printf '%s\n' 'frame_files "$@"'; } | on_frame config "$1" "$2"
}
if (( retro_rewind )); then
note "pointing Retro Rewind at its pack"
set_frame_path retro_rewind_root "$frame_pack_path"
fi
EOF
note "pointing the game at the disc"
set_frame_path dvd_root "$frame_disc_path"
# Steam's library: through Valve's devkit tools when Frame Control or the Devkit Client put them on
# the Frame, as a Steam Linux Runtime ARM64 title (Steam starts an ARM64 program natively).
@@ -672,9 +955,53 @@ esac
register_game "$game_id" "$work_dir/out"
if (( retro_rewind )); then register_game RetroRewind "$work_dir/out-retro-rewind"; fi
# ---------------------------------------------------------------------------------------------
# Updating from inside the game. Only an install on the Frame itself can do it, since that is the
# only one with the build here; installed from a PC, the game's Updates tab says to update there.
if [[ "$frame" == local ]]; then
note "setting up the game's Update button"
update_script="$work_dir/steam-frame-install.sh"
# A copy, so an update never runs from the source tree it is about to overwrite.
cp -f "$source_dir/Launcher/steam-frame-install.sh" "$update_script"
chmod +x "$update_script"
mkdir -p "$game_data" "$HOME/.config/systemd/user"
cat > "$game_data/update.conf" <<EOF
# Written by steam-frame-install.sh: what the game's Updates tab runs. Remove this file to take the
# Update button away, and the tab goes back to saying how to update from a PC.
script=$update_script
work_dir=$work_dir
service=wiicompiled-update.service
EOF
# Its own service, so the update outlives the game that started it: Steam stops the game's own
# process group when the game closes.
cat > "$HOME/.config/systemd/user/wiicompiled-update.service" <<EOF
[Unit]
Description=WiiCompiled VR update
[Service]
Type=oneshot
Environment=WIICOMPILED_UPDATE_STATUS=$game_data/update-status
Environment=WIICOMPILED_UPDATE_REQUEST=$game_data/update-request
# Below the game in line for the processor and the storage, so it can keep running meanwhile.
Nice=19
IOSchedulingClass=idle
ExecStart=$update_script --update --work-dir "$work_dir"
EOF
systemctl --user daemon-reload >/dev/null 2>&1 ||
note "(systemd did not reload, so the Update button will not work until the Frame restarts)"
fi
save_settings
say "Done"
note "Start $game_id from your library in the headset. Settings: left shoulder button, VR tab."
status done "Updated${release:+ to }${release}"
if [[ "$mode" == update ]]; then
note "Updated${release:+ to }$release."
else
note "Start $game_id from your library in the headset. Settings: left shoulder button, VR tab."
fi
if (( retro_rewind )); then note "RetroRewind is beside it, and shares its settings."; fi
relaunch
}
main "$@" </dev/null
+92
View File
@@ -0,0 +1,92 @@
import os
from pathlib import Path
import subprocess
import tempfile
import tomllib
import unittest
from unittest.mock import patch
SCRIPT = Path(__file__).resolve().parents[1] / 'steam-frame-install.sh'
TEXT = SCRIPT.read_text()
CODE = TEXT.split("<<'FRAME_FILES_PY'\n", 1)[1].split('\nFRAME_FILES_PY', 1)[0]
OPERATIONS = {'__name__': 'frame_tests'}
exec(compile(CODE, str(SCRIPT), 'exec'), OPERATIONS)
class FrameInstallerTests(unittest.TestCase):
def test_source_membership_preserves_user_data_and_unchanged_timestamps(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root)
source, live = root / 'source', root / 'live'
source.mkdir()
live.mkdir()
(source / 'keep.cpp').write_text('same')
(live / 'keep.cpp').write_text('same')
os.utime(live / 'keep.cpp', (100, 100))
(live / 'removed.cpp').write_text('old')
(live / '.release-files').write_text('keep.cpp\nremoved.cpp\n')
(live / 'Assets').mkdir()
(live / 'Assets/main.dol').write_text('disc')
(live / 'native-build').mkdir()
(live / 'native-build/cache').write_text('cache')
OPERATIONS['sync_source'](source, live)
self.assertFalse((live / 'removed.cpp').exists())
self.assertEqual(100, (live / 'keep.cpp').stat().st_mtime)
self.assertEqual('disc', (live / 'Assets/main.dol').read_text())
self.assertEqual('cache', (live / 'native-build/cache').read_text())
def test_paths_replace_only_the_right_section_and_escape_values(self):
with tempfile.TemporaryDirectory() as home, patch.dict(os.environ, HOME=home):
config = Path(home) / '.local/share/WiiCompiled/Config.toml'
config.parent.mkdir(parents=True)
config.write_text('[other]\ndvd_root = "untouched"\n[ "paths" ] # paths\n"dvd_root" = "old"\n[video]\nscale = 2')
value = '/media/My Games/a"b\\c\nfolder'
OPERATIONS['set_path']('dvd_root', value)
parsed = tomllib.loads(config.read_text())
self.assertEqual(value, parsed['paths']['dvd_root'])
self.assertEqual('untouched', parsed['other']['dvd_root'])
self.assertEqual(2, parsed['video']['scale'])
OPERATIONS['set_path']('retro_rewind_root', 'packs/My Pack')
self.assertEqual(str(Path(home) / 'packs/My Pack'), tomllib.loads(config.read_text())['paths']['retro_rewind_root'])
def test_ssh_keeps_argument_boundaries(self):
function = TEXT.split('on_frame() {', 1)[1].split('\n}\n', 1)[0]
script = 'ssh_opts=(); frame=remote\nssh() { bash -c "${@: -1}"; }\non_frame() {' + function + '\n}\non_frame "$@"'
arguments = ['/media/My Games/disc', "quote'and$characters", '']
result = subprocess.run(['bash', '-c', script, 'test', *arguments], input='printf "%s\\0" "$@"\n', capture_output=True, text=True, check=True)
self.assertEqual('\0'.join(arguments) + '\0', result.stdout)
def test_lock_blocks_a_second_installer(self):
locking = TEXT.split('command -v flock >/dev/null || fail', 1)[1].split('\n', 1)[1].split('command -v python3', 1)[0]
setup = 'work_dir=$1; fail() { echo "$*" >&2; exit 1; }\n'
with tempfile.TemporaryDirectory() as root:
holder = subprocess.Popen(['bash', '-c', setup + locking + '\necho ready; read -r', 'test', root], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
try:
self.assertEqual('ready\n', holder.stdout.readline())
contender = subprocess.run(['bash', '-c', setup + locking, 'test', root], capture_output=True, text=True)
self.assertEqual(1, contender.returncode)
self.assertIn('Another install or update', contender.stderr)
finally:
holder.communicate('\n')
self.assertEqual(0, subprocess.run(['bash', '-c', setup + locking, 'test', root]).returncode)
def test_unsigned_payload_stops_before_translation(self):
build = SCRIPT.with_name('local-build.sh').read_text()
block = build.split('if (( builds_retro && !skip_retro_wfc_payload )); then', 1)[1].split('\nfi', 1)[0]
setup = 'builds_retro=1; skip_retro_wfc_payload=0; retro_wfc_offline_dir="/payload folder"\n'
check = 'translator() { [[ "$1" == validate-retro-wfc-payload && "$2" == --directory && "$3" == "/payload folder" ]] || exit 99; return 23; }\n'
result = subprocess.run(['bash', '-e', '-c', setup + check + block + '\necho translated'], capture_output=True, text=True)
self.assertEqual(23, result.returncode)
self.assertNotIn('translated', result.stdout)
def test_config_function_can_be_sent_to_remote_bash(self):
function = TEXT.split('frame_files() {', 1)[1].split('\n}\n', 1)[0]
with tempfile.TemporaryDirectory() as home:
result = subprocess.run(['bash', '-s', '--', 'config', 'dvd_root', '/new disc'], input='frame_files() {' + function + '\n}\nframe_files "$@"\n', text=True, capture_output=True, env={**os.environ, 'HOME': home})
self.assertEqual(0, result.returncode, result.stderr)
config = Path(home) / '.local/share/WiiCompiled/Config.toml'
self.assertEqual('/new disc', tomllib.loads(config.read_text())['paths']['dvd_root'])
if __name__ == '__main__':
unittest.main()
+10 -1
View File
@@ -147,6 +147,15 @@ headroom. It is live: **F10 → VR → Render resolution** (also on the headset
in percent, applies it when the slider is let go, and saves it. Below the slider, *Each eye* gives
the left eye's size now and, while they differ, the size the slider's value gives.
`adaptive_resolution` (default off; *Adaptive resolution (experimental)* on the VR tab) lowers the
race's eye resolution in 10% steps, down to 70% of `render_scale`, while new eye frames fall below
85% of the game's 60 FPS, and raises it again after three seconds on time. The eyes render into the
top-left corner of the full swapchain image and the projection layer shows only that corner, so the
compositor scales them up and no swapchain is rebuilt; each step does rebuild the foveation maps.
`console.log` records every change as `OpenXR: adaptive resolution`. It is offered on the Quest and
the Linux (Steam Frame) build, not on Windows, whose bridges copy whole eyes. Adapted from
heurazy's [Wiicompiled_VR-PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS).
A new scale never interrupts the picture. Each backend keeps two swapchain pairs, one on display and
one Aurora writes next, and rebuilds only the second, at the start of the frame that writes it; the
other follows a frame later, once it is the one written. The new swapchains are created before the
@@ -1282,7 +1291,7 @@ custom DLL's ABI through three borrowed-image copy/readback cycles; run it with
| 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`. |
| Android Vulkan (Steam Frame) | The same backend in the `steamFrame` flavour, for SteamVR's Android runtime under Lepton: Frame controller profile, 120 Hz request, eye-tracked foveation. Blocked on the headset: Lepton's Turnip exposes no AHardwareBuffer or fd external memory, so the eyes cannot cross devices. The native build below is the supported route. See the README, The Android flavour. |
| Android Vulkan (Steam Frame) | The same backend in the `steamFrame` flavour, for SteamVR's Android runtime under Lepton: Frame controller profile, 120 Hz request, eye-tracked foveation. Blocked on the headset: Lepton's Turnip exposes no AHardwareBuffer or fd external memory, so the eyes cannot cross devices. The native build below is the supported route. See the README, How it works. |
| Linux Vulkan (Steam Frame, SteamOS) | Implemented and played on a Steam Frame (beta): the Windows Vulkan design compiled for Linux, so the runtime creates Dawn's own instance and device and the eyes are copied on Dawn's queue with no sharing. Adds the Frame controller profile, 120 Hz and gaze-centred foveation. Needs a Dawn built with Aurora's patches (`Launcher/build-dawn-linux.sh`), then `Launcher/local-build.sh --openxr --dawn-package <dir> --headset steam_frame`. See the README, Quick start. |
| Other platforms | Not wired yet. |
+316 -283
View File
@@ -9,50 +9,65 @@
<a href="LICENSE"><img alt="License: GPLv3" src="https://img.shields.io/badge/license-GPLv3-2EA44F?logo=gnu&amp;logoColor=white"></a>
</p>
Mario Kart Wii in VR on Valve's Steam Frame, running natively on SteamOS: a fork of
[WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR) (itself built on
[WiiCompiled](https://github.com/patchzyy/Wiicompiled)), the static recompilation of Mario Kart Wii
to native code. There is no emulator, interpreter or JIT at runtime: your own disc is translated to
C++ and compiled for the Frame's ARM64 CPU, and it renders through SteamVR's OpenXR runtime.
Mario Kart Wii in VR on Valve's Steam Frame, running natively on SteamOS. It is built on
[WiiCompiled](https://github.com/patchzyy/Wiicompiled), the static recompilation of Mario Kart Wii
to native code, and its VR fork [WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR).
Nothing is emulated: your own disc is translated to C++, compiled for the Frame's ARM64 processor,
and drawn through SteamVR.
> [!IMPORTANT]
> There is no Nintendo code, no assets and no game data anywhere in this project. You need your
> own legally dumped copy of the PAL version of the game; the translation runs on your machine
> against your disc image, and nothing is uploaded.
> This project contains no Nintendo code, assets or game data. You need your own dump of the PAL
> disc. The game is built from it on your own machine, and nothing is uploaded or shared.
> [!WARNING]
> **Beta.** It runs on a Steam Frame: SteamVR, both eyes at the panels' 2160x2160, the Frame's
> controllers, 120 Hz with every game frame shown, and foveation that follows your eyes. It is not
> finished: images still double in races and on the HUD, and the foveation tracks the right eye
> less well than the left (see [Known issues](#known-issues)). Reports with the run log move it
> forward (see [Reporting problems](#reporting-problems)).
> **Beta.** It plays on the Steam Frame at the panels' full 2160x2160 per eye, at 120 Hz, with the
> Frame's controllers and foveation that follows your eyes. Some images still double in races and
> on the HUD (see [Known issues](#known-issues)).
**Contents:** [Quick start](#quick-start) · [Updating](#updating) ·
[Recommended settings](#recommended-settings) · [Controls](#controls) ·
[Known issues](#known-issues) · [Troubleshooting](#troubleshooting) ·
[Other ways to build](#other-ways-to-build) · [How it works](#how-it-works) · [FAQ](#faq) ·
[Credits](#credits)
---
## Quick start
This is the whole way from your disc to playing in the headset. An install script does the work: it
downloads the newest [release](https://github.com/mitch030504/Wiicompiled_VR_Frame/releases),
extracts your disc, builds the game in an ARM64 container on your PC, copies it and the disc to the
Frame, and adds it to your Steam library. Releases are source only: the game is always built from
your own disc, so there is nothing ready-built to download. The first build takes a few hours, most
of it compiling Dawn under emulation; later builds reuse it.
One script takes you from your disc to the game in your Steam library. It downloads the newest
[release](https://github.com/mitch030504/Wiicompiled_VR_Frame/releases) (currently `frame-beta-4`),
extracts your disc, builds the game in an ARM64 container, installs it on the Frame and adds it to
Steam. Releases hold only source code: the game is always built from your own disc.
### Where to build
You can run the script on a Linux PC or on the Frame itself:
| | On a Linux PC | On the Frame |
| --- | --- | --- |
| First build | A few hours on an x86_64 PC (ARM64 is emulated), mostly building Dawn | Slower, and the Frame needs to stay on its charger |
| Updates | Run from the PC | Also from the **Updates** tab inside the game |
| Install option | `--frame steamos@<frame-ip>` | `--frame local` |
Later builds reuse what the first one built, so updates usually take minutes.
### You need
**You need:**
- a Steam Frame with Developer Mode on (Steam Settings → System → Enable Developer Mode, then set a
user password), on the same network as your PC;
- your own clean PAL `RMCP01` disc of Mario Kart Wii: an ISO, WBFS or RVZ image (WIA, CISO, GCZ
and NFS work too), as is or in a `.zip` or `.7z`, or a folder you extracted it to;
- an x86_64 Linux PC with about 20 GB free and 16 GB of memory or more.
The PC commands below work in bash, zsh and fish.
user password);
- your own clean PAL `RMCP01` disc of Mario Kart Wii, as an ISO, WBFS, RVZ, WIA, CISO, GCZ or NFS
image, inside a `.zip` or `.7z` if you like, or as an extracted folder;
- about 20 GB free where the build goes (`~/wiicompiled-frame`, or another folder with
`--work-dir`);
- to build on a PC: Linux on x86_64 or ARM64 with 16 GB of memory or more, Python 3 and
`flock` (from util-linux), on the same network as the Frame.
> [!NOTE]
> Nobody here will tell you where to get the game. Dumping your own disc is on you, and links to
> game files won't be provided or tolerated. For the same reason there is no ready-built game to
> download: releases hold the source, and the game is always built from your own disc.
> Nobody here will tell you where to get the game, and links to game files are not tolerated.
### 1. Set up ARM64 emulation on the PC
### 1. Set up the container (PC only)
The build runs in a Debian ARM64 container. On an x86_64 PC, set up podman with ARM64 emulation:
```bash
sudo pacman -S --needed podman qemu-user-static qemu-user-static-binfmt # Arch, CachyOS
@@ -60,77 +75,128 @@ sudo systemctl restart systemd-binfmt
podman run --rm --platform linux/arm64 docker.io/library/debian:trixie uname -m
```
The last command must print `aarch64`. On Debian or Ubuntu install `podman qemu-user-static
The last command must print `aarch64`. On Debian or Ubuntu, install `podman qemu-user-static
binfmt-support` instead. If podman complains about subordinate ids, run
`sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER` and log in again.
Docker works too, in place of podman.
Docker works in place of podman. An ARM64 PC needs only podman or Docker, and the Frame already
has podman.
### 2. Run the installer
With the path to your disc image and your Frame's address:
From a PC, with your disc image and the Frame's address:
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --disc "/path/to/Mario Kart Wii.wbfs" --frame steamos@<frame-ip>
```
- `--disc` takes the image whatever its name or extension; the format is read from the file
itself. A `.zip` or `.7z` is unpacked first (with 7-Zip, bsdtar or unzip if you have one, in a
container otherwise), and the disc image inside it is found and used. It checks the game ID
before building and stops on any disc other than PAL `RMCP01`.
- It asks for the Frame's password when it gets there. If you use
[Frame Control](https://github.com/saphid/frame-control), pass `--frame frame` instead: its SSH
key answers to that name.
- Everything it builds lives in `~/wiicompiled-frame` (`--work-dir` to change that): the toolchain,
Dawn, the source, the extracted disc and the build log, `build.log`.
- It runs as many compiles at once as fit in memory (a quarter of your memory in GB). If the machine
still freezes, run it again with a lower `--jobs`, such as `--jobs 2`.
- If it stops for any reason, the same command picks up where it left off.
- On the Frame, the game goes to `~/devkit-game/WiiCompiled/` and the disc to `~/wiicompiled/disc`.
It is added to your Steam library through Valve's devkit tools in `~/devkit-utils`, which Frame
Control and Valve's Devkit Client put there. Without them, the script says how to add it once
yourself. Steam must be running on the Frame for this step.
- Add `--retro-rewind` to also build [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind).
The script downloads its pack (about 4 GB) from Retro Rewind's own update server, as Wheel Wizard
does, and later runs apply only the updates published since. It also fetches the Retro-WFC
payload online play needs. On the Frame, Retro Rewind goes to `~/devkit-game/RetroRewind/` and
its pack to `~/wiicompiled/RetroRewind6`, and it shares WiiCompiled's settings.
`--retro-rewind-pack DIR` uses a RetroRewind6 folder you already have instead.
- `--help` lists every option. From a downloaded release, run `Launcher/steam-frame-install.sh`
with the same options: it then builds that release's source.
Or on the Frame, over SSH (`ssh steamos@<frame-ip>`) or in a Desktop Mode terminal:
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --disc "/path/to/Mario Kart Wii.wbfs" --frame local
```
`--disc` accepts any of these:
| Extension | What it is |
| --- | --- |
| `.iso` | A plain disc image |
| `.wbfs` | Wii Backup File System image |
| `.rvz` | Dolphin's compressed image |
| `.wia` | Wii ISO Archive |
| `.ciso` | Compact ISO |
| `.gcz` | Dolphin's older compressed image |
| `.nfs` | A Wii game image from a Wii U |
| `.zip`, `.7z` | An archive holding one of the images above |
| a folder | An extracted disc, holding `sys/` and `files/` |
The script reads the format from the file's contents, so a renamed file works too.
The script checks that the disc is PAL `RMCP01` before building, and stops on any other. If it
stops for any reason, the same command picks up where it left off. From a PC, it asks for the
Frame's password when it gets there. Steam must be running on the Frame for the last step, adding
the game to the library.
Useful options (`--help` lists them all):
| Option | What it does |
| --- | --- |
| `--retro-rewind` | Also builds [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) and adds it to Steam as **RetroRewind**. Its pack (about 4 GB) comes from Retro Rewind's own update server, as Wheel Wizard fetches it, along with the Retro-WFC payload online play needs. It shares WiiCompiled's settings. |
| `--retro-rewind-pack DIR` | Uses a RetroRewind6 folder you already have instead of downloading one. |
| `--work-dir DIR` | Where the build lives instead of `~/wiicompiled-frame`, such as a folder on the Frame's SD card. |
| `--jobs N` | How many files compile at once. It defaults to a quarter of your memory in GB; lower it, to `--jobs 2` for example, if the machine freezes. |
| `--frame frame` | With [Frame Control](https://github.com/saphid/frame-control) set up, its SSH key answers to `frame`, so no password is asked. |
| `--release TAG` | Builds a given release instead of the newest. |
Leaving out `--frame` only builds the game, into `out` in the work dir.
On the Frame the game goes to `~/devkit-game/WiiCompiled/`. Installed from a PC, the disc goes to
`~/wiicompiled/disc`; with `--frame local` it stays in the work dir. Adding the game to Steam uses
Valve's devkit tools in `~/devkit-utils`, which Frame Control and Valve's Devkit Client put there;
without them, the script says how to add it yourself.
### 3. Play
Start **WiiCompiled** from the library in the headset. Open the settings panel with the left
shoulder button, go to the **VR** tab and set the [recommended settings](#recommended-settings).
Start **WiiCompiled** from your library in the headset. Press the left shoulder button for the
settings panel, and set the [recommended settings](#recommended-settings) on its **VR** tab.
### Updating
## Updating
Run the same command again; you can leave out `--disc`. The script downloads the newest release,
puts only the files that changed over the old source, rebuilds what they touch, and replaces the
game on the Frame. Dawn is only rebuilt when a release changes its patches (the release notes say
so), so an update usually takes minutes. `--release <tag>` builds a given release instead.
### From the machine you installed from
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --update
```
`--update` reuses the options you installed with, which the install saved in `install.conf` in the
work dir. Add `--work-dir` if you used one; any option you pass wins over the saved one. It
downloads the newest release, rebuilds only what changed and replaces the game on the Frame. When
the Frame already has the newest release (and the newest Retro Rewind pack, if you use it), it says
so and builds nothing. `--check` only says whether an update is available.
An install made with `--frame local` keeps a copy of the script in its work dir, so on the Frame
`~/wiicompiled-frame/steam-frame-install.sh --update` works too.
### From inside the game
An install made on the Frame with `--frame local` adds an **Updates** tab to the settings panel. It
shows the release you are running; **Check for updates** looks for a new one, and **Update now**
builds it in the background while you keep playing (the game can stutter while it compiles). The
tab shows each step and how far the build is, and tells you when to restart the game. If you close
the game meanwhile, the update carries on and opens the game again when it is done. Keep the Frame
on its charger while it runs.
The update runs as the systemd user service `wiicompiled-update.service`, at low priority so the
game comes first. Installed from a PC, the tab says to update from the PC instead. Progress is not
shown as Steam notifications, because Steam on the Frame does not display them.
### Coming from frame-beta-2 or earlier
Older installs saved no options for `--update` to reuse. Update once with the full install command
from [step 2](#2-run-the-installer) (you can leave out `--disc`); from then on `--update` works. To
get the in-game Updates tab, make that install on the Frame with `--frame local`.
## Recommended settings
All of these are in the headset's settings panel (left shoulder button, **VR** tab) and in
`~/.local/share/WiiCompiled/Config.toml`. Quit the game before editing the file; it writes its
settings back when it closes.
All of these are on the settings panel's **VR** tab and in `~/.local/share/WiiCompiled/Config.toml`
on the Frame. Quit the game before editing the file, since the game saves its settings to it.
| Setting | Value | Why |
| --- | --- | --- |
| `[vr] render_scale` | `1.25` | Scales SteamVR's recommended eye size, 1728x1728 on the Frame: 1.25 is the panels' native 2160x2160, which the Frame renders in 9 to 11 ms a frame with medium foveation. |
| `[vr] foveation` | `medium` | `off` shades every pixel and costs the most. See [Known issues](#known-issues) if images double. |
| `[vr] repeat_frames` | `true` (default) | Without it SteamVR halves the game's rate and fills refreshes itself. |
| `[vr] frame_interpolation_fps` | `0` | Rendering in-between frames needs 120 eye pairs a second, which made things worse on the Frame. |
| `[vr] adaptive_resolution` | `false` (default) | Experimental and untested on the Frame. When on, races drop to as little as 70% of `render_scale` while new frames fall behind 60 FPS, and climb back once they keep up. Each step rebuilds the foveation maps; `console.log` records every change as `OpenXR: adaptive resolution`. |
| `[vr] render_scale` | `1.25` | 1.25 times SteamVR's recommended 1728x1728 is the panels' native 2160x2160, which the Frame renders in 9 to 11 ms a frame with medium foveation. |
| `[vr] foveation` | `medium` (default) | `off` shades every pixel and costs the most. See [Known issues](#known-issues) if images double. |
| `[vr] repeat_frames` | `true` (default) | Without it SteamVR halves the game's rate and fills in refreshes itself. |
| `[vr] frame_interpolation_fps` | `0` (default) | In-between frames need 120 eye pairs a second, which made things worse on the Frame. |
| `[vr] adaptive_resolution` | `false` (default) | Experimental and not yet tried in a race on the Frame. When on, races drop to as little as 70% of `render_scale` while frames fall behind 60 FPS, and climb back once they keep up. |
| `[video] resolution_multiplier` | `2` | The game's own frame, which the eyes are made from. 4x is far too heavy for the Frame's GPU. |
Keep SteamVR's refresh rate at 120 Hz. Motion Smoothing makes no difference to this game.
Keep SteamVR at 120 Hz. Motion Smoothing makes no difference to this game.
## Controls
The Frame's controllers are bound through their own profile, so the left D-pad works:
The Frame's controllers have their own bindings, so the left D-pad works:
| Frame controller | Wii Remote mode | Gamepad mode |
| --- | --- | --- |
@@ -147,26 +213,25 @@ The Frame's controllers are bound through their own profile, so the left D-pad w
## Known issues
- **Doubled images** in races and on the HUD, worst while racing, sometimes in the right eye only.
Both eyes get the same frames, so a doubling in one eye points at foveation: the Frame's driver
(Turnip) draws a foveated screen tile at lower resolution and scales it back up, and each eye's
density maps change with its gaze. Try foveation **Off** if it bothers you, and report whether it
helped.
- **Foveation follows the right eye less well** than the left. Convergence on near content (the HUD
screen at 2 m, the cockpit) is not yet corrected for.
- **Doubled images** in races and on the HUD, worst while racing and sometimes in one eye only.
Foveation is the main suspect: the Frame's graphics driver draws the outer areas at lower
resolution and scales them up, and each eye's foveation follows its own gaze. Try foveation
**Off** if it bothers you, and report whether it helped.
- **Foveation follows the right eye less well** than the left. Eyes converging on near things (the
HUD screen, the cockpit) are not yet corrected for.
- **VR frame interpolation** is not recommended on the Frame.
- **The Quest app's `steamFrame` flavour** cannot show a picture in the Frame's Android layer
([below](#the-android-flavour)). Use the native build.
- **The Quest app's Steam Frame version** shows no picture in the Frame's Android layer (see
[How it works](#how-it-works)). Use this native build.
## Troubleshooting
**Everything at once.** From the PC, `Launcher/frame-diagnostics.sh --frame steamos@<frame-ip>` (or
`--frame frame` with Frame Control) gathers the newest runs' logs and crash files, `Config.toml`,
SteamVR's logs, the GPU and Vulkan driver, and the system's state into one `.tar.gz`. Its
`summary.txt`, also printed at the end, gives the startup steps below that the newest run reached.
`--help` lists the options.
**Collect everything at once.** From a PC, run
`Launcher/frame-diagnostics.sh --frame steamos@<frame-ip>` from the source
(`~/wiicompiled-frame/source` after an install). It gathers the newest runs' logs and crash files,
`Config.toml`, SteamVR's logs, the GPU driver and the system's state into one `.tar.gz`, and its
`summary.txt` says how far the newest run got through the startup steps below.
**Logs.** Each run gets a folder under `~/.local/share/WiiCompiled/Logs/` on the Frame, holding
**Logs.** Each run gets a folder under `~/.local/share/WiiCompiled/Logs/` on the Frame, with
`console.log` and, after a crash, `crash_sigsegv.txt`. A working start logs, in order:
1. `OpenXR initialized: runtime 'SteamVR/OpenXR'`;
@@ -178,41 +243,80 @@ SteamVR's logs, the GPU and Vulkan driver, and the system's state into one `.tar
7. `OpenXR eye gaze: available`, then `tracking`.
The first one missing says where it stopped. `Linux Vulkan OpenXR requires a Dawn built with
Aurora's patches` means the game was built without `--dawn-package`.
Aurora's patches` means the game was built by hand without `--dawn-package`.
**Smoothness.** With `[diagnostics] openxr_logging = true`, the log gets a pacing line every second:
**Smoothness.** With `[diagnostics] openxr_logging = true` in `Config.toml`, the log gets a pacing
line every second. On the Frame:
```bash
ssh frame 'cd ~/.local/share/WiiCompiled/Logs && d=$(ls -t | head -1) && grep -h "xr-diag\] 1.0" "$d/console.log" | tail -5'
cd ~/.local/share/WiiCompiled/Logs && grep -h "xr-diag\] 1.0" "$(ls -t | head -1)/console.log" | tail -5
```
A healthy race reads `predicted-rate=120.0Hz`, `new=60 repeat=60` and `late=0`. Fewer than 60 `new`
A smooth race reads `predicted-rate=120.0Hz`, `new=60 repeat=60` and `late=0`. Fewer than 60 `new`
frames means the GPU is over budget: lower `render_scale` or `resolution_multiplier`.
**Crashes.** `console.log` then names the faulting thread and gives its pc and a backtrace as
`module+offset`. On the build machine,
`addr2line -f -C -e ~/wiicompiled-frame/source/native-build/WiiCompiled 0x<offset>` turns an offset
in `WiiCompiled` into a function: the executable is not stripped. (Built by hand, the executable is
in `~/wiicompiled/Wiicompiled_VR_Frame/native-build/`.)
**Crashes.** `console.log` names the crashed thread and gives a backtrace as `module+offset`. On the
build machine, `addr2line -f -C -e ~/wiicompiled-frame/source/native-build/WiiCompiled 0x<offset>`
turns an offset in `WiiCompiled` into a function name.
**Building.** The install script's log is `~/wiicompiled-frame/build.log`; its end says why a build
stopped. A build that freezes the machine has run out of memory: lower `--jobs` (or, by hand,
`--parallel`). A missing CMake package means installing its `-dev` package in the container and
running the same command again.
**Builds.** The log is `build.log` in the work dir, and its end says why a build stopped. A build
that freezes the machine has run out of memory: run it again with a lower `--jobs`.
## Building by hand
**In-game updates.** The Updates tab shows the step that failed. The full output is in the Frame's
journal, `journalctl --user -u wiicompiled-update.service`, and the build's in `build.log` in the
work dir.
These are the steps the install script runs, for when you want to see or change each one. Set up
emulation as in [Quick start](#quick-start) step 1 first.
### Reporting problems
### 1. Download the release and extract your disc
Open an [issue](https://github.com/mitch030504/Wiicompiled_VR_Frame/issues) saying what you did and
what you saw (which eye, where in the picture, racing or in menus). Attach the diagnostics archive,
or at least the run's `console.log`; for a build problem, the end of `build.log`. Problems that also
happen on a PC or a Quest belong upstream, in
[WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR).
Take the newest release from the [Releases](https://github.com/mitch030504/Wiicompiled_VR_Frame/releases)
page; the commands use `frame-beta-1`, so put the newest release's tag in its place:
## Other ways to build
### On a server
The script runs with Docker as well as podman, and much faster with more cores and memory. A
machine that can't reach the Frame builds without `--frame`; give it more compiles at once if it has
the memory, such as `--jobs 8` for 32 GB:
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --disc "/path/to/Mario Kart Wii.wbfs" --jobs 8
```
Run it in `tmux` or `screen` so a closed SSH session doesn't stop it. Then copy
`~/wiicompiled-frame/out` and `~/wiicompiled-frame/disc` to the Frame as in
[By hand, step 3](#3-install-on-the-frame).
On some hosts, such as Unraid 7 with kernel 6.18, `binfmt_misc` registrations are per container:
`tonistiigi/binfmt --install arm64` reports success, but Debian answers `exec format error`.
Register qemu on the host instead, with the `P` flag the tonistiigi build of qemu expects (without
it every program loses its first argument, and `uname -m` prints `Linux`):
```bash
docker create --name qemu-src tonistiigi/binfmt
docker cp qemu-src:/usr/bin/qemu-aarch64 /usr/local/bin/qemu-aarch64
docker rm qemu-src
echo ':qemu-aarch64:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/local/bin/qemu-aarch64:POCF' > /proc/sys/fs/binfmt_misc/register
docker run --rm --platform linux/arm64 debian:trixie uname -m # aarch64
```
Unraid keeps `/usr/local/bin` in memory, so repeat this after a reboot.
### By hand
These are the steps the install script runs, for when you want to see or change one. Set up the
container as in [Quick start](#1-set-up-the-container-pc-only) first. The commands use
`frame-beta-4`; put the newest release's tag in its place.
#### 1. Download the release and extract your disc
```bash
mkdir -p ~/wiicompiled/Wiicompiled_VR_Frame; cd ~/wiicompiled
curl -fL https://github.com/mitch030504/Wiicompiled_VR_Frame/archive/refs/tags/frame-beta-1.tar.gz \
curl -fL https://github.com/mitch030504/Wiicompiled_VR_Frame/archive/refs/tags/frame-beta-4.tar.gz \
| tar -xz --strip-components=1 -C Wiicompiled_VR_Frame
curl -fL -o nodtool https://github.com/encounter/nod/releases/download/v2.0.0-alpha.10/nodtool-linux-x86_64
chmod +x nodtool
@@ -221,15 +325,16 @@ mkdir -p Wiicompiled_VR_Frame/Assets
cp disc-extract/sys/main.dol disc-extract/files/rel/StaticR.rel Wiicompiled_VR_Frame/Assets/
```
`disc-extract` must hold `sys/` and `files/` directly; keep it, the game reads it when it runs.
`disc-extract` must hold `sys/` and `files/` directly. Keep it: the game reads it while it runs. On
an ARM64 machine, download `nodtool-linux-aarch64` instead.
### 2. Build, inside an ARM64 container
#### 2. Build, inside an ARM64 container
```bash
podman run -it --name wiicompiled-frame --platform linux/arm64 -v ~/wiicompiled:/work docker.io/library/debian:trixie bash
```
Then, in the container's bash prompt:
Then, at the container's prompt:
```bash
apt-get update && apt-get install -y --no-install-recommends \
@@ -252,20 +357,21 @@ exit
- Dawn is done when it prints `Patched Dawn for Linux ready`, the game when it prints
`MKWCBUILD:OUTPUT=/work/out`.
- `--jobs 4` and `--parallel 4` suit 16 GB of memory. Compiles take more memory under emulation, and
16 at once froze a 16 GB laptop. Use 8 with 32 GB.
- If it stops, `podman start -ai wiicompiled-frame` gets you back in. Run `cd /work/Wiicompiled_VR_Frame;
T=/work/tools/toolchain-aarch64/bin`, then the step that stopped: both scripts resume.
- `--jobs 4` and `--parallel 4` suit 16 GB of memory, since compiles take more memory under
emulation. Use 8 with 32 GB.
- If it stops, `podman start -ai wiicompiled-frame` gets you back in. Run
`cd /work/Wiicompiled_VR_Frame; T=/work/tools/toolchain-aarch64/bin`, then the step that stopped:
both scripts resume.
- On the Frame or another ARM64 machine, start the container without `--platform linux/arm64`.
### 3. Install on the Frame
#### 3. Install on the Frame
The easiest way is [Frame Control](https://github.com/saphid/frame-control): set up its connection
to the Frame, then drag `~/wiicompiled/out` onto **Send to Frame**. Name it `WiiCompiled` and keep
**Launches** on `WiiCompiled`. It adds the game to your Steam library, in `~/devkit-game/WiiCompiled/`
on the Frame.
With [Frame Control](https://github.com/saphid/frame-control), drag `~/wiicompiled/out` onto
**Send to Frame**, name it `WiiCompiled` and keep **Launches** on `WiiCompiled`. That adds it to
your Steam library, in `~/devkit-game/WiiCompiled/` on the Frame.
Then copy the disc over and, on a first install, tell the game where it is (Frame Control's SSH
key answers to `frame`; use `steamos@<frame-ip>` otherwise):
Then copy the disc over and tell the game where it is (Frame Control's SSH key answers to `frame`;
use `steamos@<frame-ip>` otherwise):
```bash
ssh frame mkdir -p wiicompiled
@@ -273,205 +379,132 @@ scp -r ~/wiicompiled/disc-extract frame:wiicompiled/disc
ssh frame 'mkdir -p ~/.local/share/WiiCompiled && printf "[paths]\ndvd_root = \"/home/steamos/wiicompiled/disc\"\n" > ~/.local/share/WiiCompiled/Config.toml'
```
Without Frame Control, `scp -r ~/wiicompiled/out frame:wiicompiled/` and start
`~/wiicompiled/out/WiiCompiled` from a terminal in the Frame's Desktop Mode, with SteamVR running.
The last line replaces `Config.toml`, so run it on a first install only. Without Frame Control,
`scp -r ~/wiicompiled/out frame:wiicompiled/` and start `~/wiicompiled/out/WiiCompiled` from a
terminal in the Frame's Desktop Mode, with SteamVR running.
### Updating by hand
#### Updating by hand
When a new release comes out, put its changed files over the old source, rebuild, and replace only
the executable. `rsync -c` copies just the files whose content changed and stamps them with the
current time, so the build recompiles exactly those. Unpacking over the source would restore each
file's commit date, which can be older than the last build, and changes would be skipped. Your
`Assets/` and build folders stay. Install `rsync` if your system lacks it, and put the new release's
tag in place of `frame-beta-2`:
Copy the new release's changed files over the old source, rebuild, and replace only the executable.
`rsync -c` copies just the files whose content changed and stamps them with the current time, so
the build recompiles exactly those; unpacking straight over the source would keep each file's
older commit date, and changes could be skipped. Your `Assets/` and build folders stay.
```bash
cd ~/wiicompiled
mkdir -p release-new
curl -fL https://github.com/mitch030504/Wiicompiled_VR_Frame/archive/refs/tags/frame-beta-2.tar.gz \
curl -fL https://github.com/mitch030504/Wiicompiled_VR_Frame/archive/refs/tags/frame-beta-4.tar.gz \
| tar -xz --strip-components=1 -C release-new
rsync -rcE release-new/ Wiicompiled_VR_Frame/
rm -rf release-new
podman start -ai wiicompiled-frame
# in the container: cd /work/Wiicompiled_VR_Frame; T=/work/tools/toolchain-aarch64/bin, then the
# build-dawn-linux.sh and local-build.sh lines from step 2, then exit. Dawn only rebuilds when the
# release changed its patches (the release notes say so); otherwise both finish quickly.
# build-dawn-linux.sh and local-build.sh lines from step 2, then exit. Dawn is only rebuilt when the
# release changed its patches (the release notes say so).
scp ~/wiicompiled/out/WiiCompiled frame:devkit-game/WiiCompiled/WiiCompiled.new
ssh frame 'cd ~/devkit-game/WiiCompiled && chmod 755 WiiCompiled.new && mv -f WiiCompiled.new WiiCompiled'
```
The executable is copied under a new name and moved into place, which works even while an old copy
is open.
## Other ways to build
**On the Frame itself.** SteamOS's root file system is read-only, but it ships podman, and the
install script runs there too. Over SSH (`ssh steamos@<frame-ip>`), or in a Desktop Mode terminal:
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --disc "/path/to/Mario Kart Wii.wbfs" --frame local
```
It is native ARM64, so no emulation, but the Frame has less memory and cooling than a PC; keep it
on its charger. The game then reads the disc straight from `~/wiicompiled-frame/disc`. By hand, the
steps under [Building by hand](#building-by-hand) work the same in a container started without
`--platform linux/arm64`, with `nodtool-linux-aarch64` in place of `nodtool-linux-x86_64`.
**On a stronger machine** (a server, for example). The script works with Docker as well as podman,
and runs much faster with more cores and memory. A machine that cannot reach the Frame builds
without `--frame`; give it more compiles at once if it has the memory, `--jobs 8` for 32 GB:
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --disc "/path/to/Mario Kart Wii.wbfs" --jobs 8
```
Run it in `tmux` or `screen` so a closed SSH session does not stop it. Copy `~/wiicompiled-frame/out`
and `~/wiicompiled-frame/disc` back to the PC, send `out` to the Frame with Frame Control and copy
the disc over as in [Install on the Frame](#3-install-on-the-frame).
On some hosts, for example Unraid 7 with kernel 6.18, `binfmt_misc` registrations are per container.
`tonistiigi/binfmt --install arm64` then reports success, but Debian answers `exec format error`.
Register qemu on the host instead, with the `P` flag the tonistiigi build of qemu expects. Without it,
every program loses its first argument, and `uname -m` prints `Linux`:
```bash
docker create --name qemu-src tonistiigi/binfmt
docker cp qemu-src:/usr/bin/qemu-aarch64 /usr/local/bin/qemu-aarch64
docker rm qemu-src
echo ':qemu-aarch64:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/local/bin/qemu-aarch64:POCF' > /proc/sys/fs/binfmt_misc/register
docker run --rm --platform linux/arm64 debian:trixie uname -m # aarch64
```
Unraid keeps `/usr/local/bin` in memory, so repeat this after a reboot.
Copying under a new name and moving it into place works even while the game is open.
## How it works
- **Same-device rendering.** SteamVR's OpenXR runtime creates the Vulkan instance and device that
Dawn (Aurora's WebGPU layer) renders with, through Aurora's patches to Dawn
- **One GPU device.** SteamVR's OpenXR runtime creates the Vulkan device that Dawn (the WebGPU
layer under Aurora, the game's renderer) draws with, through Aurora's patches to Dawn
(`aurora-main/patches/dawn`). Each eye is copied into SteamVR's swapchain on Dawn's own queue, so
nothing is shared between GPU devices. This is the PC's Vulkan backend
(`runtime/src/vr/openxr_vulkan_win32.cpp`), compiled for Linux. Dawn links statically, so the
patched Dawn is built once (`Launcher/build-dawn-linux.sh`). Its `aurora-dawn.json` declares the
Vulkan hook and density map ABIs that Aurora compiles against.
- **Headset only.** The game neither shows nor finishes rendering a desktop window, and is compiled
for the Frame's Cortex-X4 cores (`-mcpu=cortex-x4`, overridable with `--cpu`).
- **Every refresh from the game.** The game draws 60 frames a second. When it handed SteamVR only
those, SteamVR ran it at half rate and made up every other refresh itself, even with Motion
Smoothing off. With `[vr] repeat_frames`, the pacing thread waits for the next frame until 1.5 ms
before SteamVR's next wake, then resubmits the last frame at the pose it was rendered for.
SteamVR turns that to the current head pose.
- **Eye-tracked foveation.** `XR_EXT_eye_gaze_interaction` gives the gaze, which
`runtime/include/vr/eye_gaze.h` turns into each eye's view. Aurora picks a fragment density map
centred on the gaze, snapped to cells of about 3 degrees (`aurora-main/lib/gfx/foveation.hpp`),
and keeps up to 128 per eye. Each map has a memory block of its own: Turnip reads a map through a
host mapping, and Dawn's buffer uploads unmap the shared blocks they sub-allocate from. That
crashed the game when a race restarted, until each map got its own block.
- **120 Hz.** `[vr] refresh_rate` (default 120 on the Frame, `0` keeps the headset's own) is asked of
SteamVR through `XR_FB_display_refresh_rate`. SteamVR only offers the rate set in its own settings.
nothing has to be shared between devices. This is the PC's Vulkan backend
(`runtime/src/vr/openxr_vulkan_win32.cpp`) compiled for Linux. Dawn is linked in statically,
which is why the patched Dawn is built once on your machine (`Launcher/build-dawn-linux.sh`).
- **Headset only.** The game draws no desktop window, and is compiled for the Frame's Cortex-X4
cores (`-mcpu=cortex-x4`).
- **Tuned for ARM64.** The Frame's kernel uses 4 KiB memory pages, so the Steam Frame build skips
the page-size check on every translated memory access; it checks once at startup and refuses to
start if the size is ever different. Audio mixing uses NEON.
- **Every refresh from the game.** The game draws 60 frames a second. Given only those, SteamVR
ran it at half rate and filled every other refresh itself, even with Motion Smoothing off. With
`[vr] repeat_frames`, the pacing thread waits for the next frame until 1.5 ms before SteamVR's
next wake, then submits the last frame again with the head pose it was drawn for, and SteamVR
turns it to the current pose.
- **Eye-tracked foveation.** `XR_EXT_eye_gaze_interaction` gives the gaze, and Aurora picks a
fragment density map centred on it, snapped to cells of about 3 degrees
(`aurora-main/lib/gfx/foveation.hpp`), keeping up to 128 per eye. Each map has its own memory
block, since sharing blocks crashed the game when a race restarted.
- **120 Hz.** `[vr] refresh_rate` (120 on the Frame by default, `0` keeps the headset's own) is
requested through `XR_FB_display_refresh_rate`. SteamVR only offers the rate set in its own
settings.
- **The Android version.** The Quest app has a `steamFrame` flavour
(`android/Build-Quest.ps1 -Headset frame`) for the Frame's Android layer, Lepton. It starts in VR
there but shows no picture: the Android backend passes each eye between two GPU devices, and
Lepton's graphics driver supports none of the ways to share images between them. The native build
uses one device, which is why it is the one to play. [`docs/quest-port.md`](docs/quest-port.md)
covers the Android build.
### The Android flavour
[`OPENXR.md`](OPENXR.md) documents every VR setting and the renderer in full.
The Quest app also has a `steamFrame` flavour (`android/Build-Quest.ps1 -Headset frame`), for the
Frame's Android layer, Lepton. It builds and starts in VR there, but cannot show a picture. The
Android backend hands each eye from Dawn's device to its own OpenXR device through
`VK_ANDROID_external_memory_android_hardware_buffer` and sync fds, and Lepton's Turnip has neither,
nor `VK_KHR_external_memory_fd`. The native build needs no sharing, which is why it is the one to
play. [`docs/quest-port.md`](docs/quest-port.md) covers the Android build.
## What comes from where
## Reporting problems
This fork keeps everything [WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR) has:
menus on a virtual screen and races in stereo, a first-person cockpit with a steering wheel you can
grab and turn, the settings panel in the headset, and Retro Rewind as its own translated game. The
physics are identical to the original game, proven by ghosts that sync across the Wii, Dolphin and
WiiCompiled.
Open an issue on this repository with:
- what you did and what you saw (which eye, where in the picture, racing or menus);
- the run's `console.log` (and `crash_sigsegv.txt` after a crash) from
`~/.local/share/WiiCompiled/Logs/` on the Frame;
- or, for a build problem, the last lines of the failing step.
On top of that it adds the SteamOS build, the install, update and diagnostics scripts and the
Frame-specific rendering, and it carries fixes taken from WiiCompiled itself and from other forks:
Linux TLS for online play, faster disc reads, safer settings saves, NEON audio mixing and more.
[`CREDITS.md`](CREDITS.md) lists each with its author.
Problems that also happen on a PC or a Quest belong upstream, in
[WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR).
## From upstream
The fork keeps everything [WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR) does;
its README covers it in full. In the headset that means:
- Menus and unsupported scenes on a head-locked virtual screen, and races in immersive stereo.
- A first-person cockpit whose steering wheel or handlebar turns with your steering, and hand
steering by heurazy: grab the wheel with the tracked controllers and turn it.
- The settings panel in the headset (render scale, foveation, refresh rate, controls), saved to
`Config.toml` on the spot.
- Physics identical to the original game, proven by ghosts that sync across Wii, Dolphin and
WiiCompiled.
- [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) as its own statically translated
profile.
The PC (Windows D3D12 and Vulkan) and Meta Quest builds are still here and unchanged; see
[`OPENXR.md`](OPENXR.md) and [`docs/quest-port.md`](docs/quest-port.md). For those, use upstream's
[WheelWizard VR](https://github.com/iChris4/WheelWizard_VR/releases/latest) instead of this fork.
## A note on related projects
The PC (Windows) and Meta Quest builds are still in the source and unchanged; see
[`OPENXR.md`](OPENXR.md) and [`docs/quest-port.md`](docs/quest-port.md). To play on those, use
upstream's [WheelWizard VR](https://github.com/iChris4/WheelWizard_VR/releases/latest) rather than
this fork.
WiiCompiled, WiiCompiled OpenXR VR, Wheel Wizard, Retro Rewind and this fork are developed
**independently**, each with its **own** rules. What applies here does not automatically apply
there, and vice versa. Check each project's own CONTRIBUTING and README files.
independently, each with its own rules. Check each project's own README and CONTRIBUTING files.
## FAQ
**Is this an emulator?**
No. Everything is compiled to native ARM64 code before you press play. At runtime nothing emulates
a Wii CPU or GPU.
No. Everything is compiled to native ARM64 code before you press play. Nothing emulates a Wii
processor or GPU while you play.
**Do you provide the game, or a ready-built binary?**
No. Nothing in this repo contains Nintendo code or assets, and the translated game is never
shipped: it is built from your own disc, a one-time cost on your machine.
**Can I download a ready-built game?**
No. The game is built from your own disc, and anything built from it can't be shared. The build
is a one-time cost on your machine, and updates only rebuild what changed.
**Which game version works?**
Clean PAL `RMCP01`. Other regions and modified executables are **rejected**.
**Which version of the game works?**
Clean PAL `RMCP01` only. Other regions and modified discs are rejected.
**Why not install the Quest APK on the Frame?**
The Frame runs it in Lepton, whose graphics driver cannot share images between the two GPU devices
the Android backend uses, so it shows nothing. The native build uses one device and needs no
sharing.
**Why not install the Quest app on the Frame?**
The Frame runs Android apps in Lepton, whose graphics driver can't pass images between the two GPU
devices the Android version uses, so it shows nothing. The native build uses one device.
**Can it run on other SteamOS or Linux ARM64 devices?**
Leave out `--headset steam_frame` and pass `--cpu` for your CPU to get a generic Linux VR build;
it needs an OpenXR runtime with `XR_KHR_vulkan_enable2`. Untested.
## AI usage
AI coding tools were used during development of this project.
All translated output is verified against real hardware behavior and most importantly, physics accuracy is proven synced across Wii, Dolphin, and WiiCompiled (see FAQ).
**Can it run on other Linux ARM64 devices?**
Untested. Build by hand without `--headset steam_frame` and with `--cpu` for your processor to get a
generic Linux VR build; it needs an OpenXR runtime with `XR_KHR_vulkan_enable2`. Unlike the Steam
Frame build, it also works with memory pages larger than 4 KiB.
## Credits
- **inkwreck** - making the logo
- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
project's whole graphics layer sits on. MIT licensed.
- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
aurora's Direct3D, Vulkan and OpenGL backends.
- **[OpenXR](https://www.khronos.org/openxr/)** - the Khronos cross-platform API used by the
experimental VR renderer.
- **[WiiCompiled OpenXR VR](https://github.com/iChris4/Wiicompiled_VR)** by iChris4 and
**[WiiCompiled](https://github.com/patchzyy/Wiicompiled)** by patchzyy - the projects this fork
is built on.
- **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.
- **[Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind)** by ZPL and team - the mod
distribution this project supports.
- **[Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard)** - the mod manager this
project integrates with as a launch backend.
- **[nod](https://github.com/encounter/nod)** - nodtool, the disc image extractor.
- **[DolphinXR](https://github.com/iChris4/dolphinXR)** - the Steam Frame controller profile's
input paths.
- **[Frame Control](https://github.com/saphid/frame-control)** by saphid - installing the game into
the Frame's Steam library, and its notes on how the Frame's software fits together.
- Everyone in the static recompilation community.
Bundled third-party components and their licenses live in
This fork stands on other people's work: WiiCompiled by patchzyy, WiiCompiled OpenXR VR by iChris4,
the steering wheel and hand steering by heurazy, and fixes from heurazy's Wiicompiled_VR-PLUS,
Chris Sotraidis's KartPad, DarthMDev, BlackAndBlue95's Strikers-WiiCompiled, rooklz and nx-mod's
wiicompiled-nx. [`CREDITS.md`](CREDITS.md) says what came from whom, along with the tools and
references used: [aurora](https://github.com/encounter/aurora),
[Dawn](https://dawn.googlesource.com/dawn), [OpenXR](https://www.khronos.org/openxr/),
[Dolphin](https://github.com/dolphin-emu/dolphin), [DolphinXR](https://github.com/iChris4/dolphinXR),
[nod](https://github.com/encounter/nod), [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind),
[Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard) and
[Frame Control](https://github.com/saphid/frame-control). The logo is by Inkwreck.
Bundled third-party components and their licenses are in
[`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
## AI usage
AI coding tools were used to develop WiiCompiled and this fork. Translated output is checked
against real hardware behaviour, and physics accuracy is proven by ghosts that sync across the Wii,
Dolphin and WiiCompiled.
## License
+18 -3
View File
@@ -88,6 +88,19 @@ wheel geometry and guest reads in `runtime/src/vr/mkw_vr_first_person.cpp` and
(`runtime/include/physical_wheel.h`, `runtime/src/physical_wheel.cpp` and their test). The files
carry that attribution in their headers.
### Mozilla CA certificate bundle - MPL-2.0
The root certificates Mbed TLS verifies servers against on Linux, macOS and Android
(`runtime/assets/certs/cacert.pem`), copied next to the built game. Extracted from Mozilla's
`certdata.txt` and redistributed unmodified as curl's CA bundle.
Source: <https://curl.se/docs/caextract.html>
### Code ported from other WiiCompiled forks - GPL-3.0-or-later
Fixes taken from other GPL-3.0 forks of WiiCompiled (heurazy's Wiicompiled_VR-PLUS, KartPad,
DarthMDev, Strikers-WiiCompiled, rooklz and wiicompiled-nx) are listed with their authors and
source commits in [`CREDITS.md`](CREDITS.md).
### pugixml - MIT
Copyright (c) 2006-2025 Arseny Kapoulkine.
@@ -131,14 +144,16 @@ Source: <https://github.com/higan-emu/libco>. Full license text:
## Fetched at build time and redistributed in release builds
These are pinned in `aurora-main/extern/CMakeLists.txt`, `aurora-main/CMakeLists.txt` and
`aurora-main/cmake/AuroraDawnProvider.cmake`. They are not stored in this repository; the build
downloads them, and release installers carry the resulting binaries. Their license texts are
These are pinned in `aurora-main/extern/CMakeLists.txt`, `aurora-main/CMakeLists.txt`,
`aurora-main/cmake/AuroraDawnProvider.cmake`, and (for Mbed TLS) `runtime/CMakeLists.txt`. They are
not stored in this repository; the build downloads them - each fetch is pinned to an exact version
with a checked SHA-256 - and links or redistributes the resulting binaries. Their license texts are
included in the installer's `licenses/` folder. The Windows installer bundles the pinned source
trees themselves (fetched by `Launcher/Prepare-Dependencies.ps1`) so end-user builds run offline.
| Component | Version | License | Upstream |
| --- | --- | --- | --- |
| Mbed TLS | 3.6.7 | Apache-2.0 / GPL-2.0-or-later | <https://github.com/Mbed-TLS/mbedtls> |
| Dawn (WebGPU) | `v20260603.191052` prebuilt | BSD-3-Clause | <https://dawn.googlesource.com/dawn> |
| Tint (part of Dawn) | with Dawn | BSD-3-Clause | <https://dawn.googlesource.com/dawn> |
| DirectXShaderCompiler (`dxcompiler.dll`) | with Dawn | NCSA / University of Illinois Open Source | <https://github.com/microsoft/DirectXShaderCompiler> |
+1 -1
View File
@@ -2,7 +2,7 @@
Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 1,
Quest 2, Quest 3, Quest 3S and Quest Pro, and, as the `steamFrame` flavour, for Valve's Steam
Frame under Lepton (the [README](../README.md#the-android-flavour); the Frame is played with the native SteamOS build). The full design, build
Frame under Lepton (the [README](../README.md#how-it-works); the Frame is played with the native SteamOS build). The full design, build
walkthrough and current status live in [docs/quest-port.md](../docs/quest-port.md); this directory only
holds the Gradle project, its helper scripts, the game kit tooling
(`QuestGameKit.psm1`, `Build-QuestGame.ps1`), the on-headset build toolchain
+1
View File
@@ -28,6 +28,7 @@ val prepareRuntimeResources by tasks.registering(Copy::class) {
from(File(assets, "wii")) { into("runtime_resources/wii_bootstrap") }
from(File(assets, "dsp/dsp_coef.bin")) { into("runtime_resources") }
from(File(assets, "pipeline/initial_pipeline_cache.db")) { into("runtime_resources") }
from(File(assets, "certs/cacert.pem")) { into("runtime_resources") }
into(runtimeResources)
}
@@ -46,6 +46,13 @@ object GameLibrary {
}
fun status(context: Context, profile: GameProfile = GameProfile.selected(context)): Status {
// A killed publication must be recovered by setup before either process loads the game.
val games = File(context.filesDir, "game")
if (File(games, ".import-transaction").exists() ||
File(games, ".${profile.id}.replacement").exists() ||
File(GameStorage.gameRoot(context), ".${GameStorage.DISC_DIRECTORY}.replacement").exists() ||
profile.modPack && File(GameStorage.gameRoot(context), ".${GameStorage.MOD_DIRECTORY}.replacement").exists()
) return Status.Missing
val manifest = manifest(context, profile)
if (manifest == null || !library(context, profile).isFile) {
return Status.Missing
@@ -0,0 +1,88 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Properties
/** A persisted commit decision keeps all old folders until every new folder is installed. */
object DirectoryReplacement {
private fun backup(destination: File) = File(destination.parentFile, "${destination.name}.replaced")
fun recover(journal: File) {
if (!journal.exists()) return
val state = Properties().apply { journal.inputStream().use { load(it) } }
val committed = state.getProperty("committed") == "true"
val count = state.getProperty("count").toInt()
for (i in count - 1 downTo 0) {
val destination = File(state.getProperty("destination.$i"))
val staging = File(state.getProperty("staging.$i"))
val old = backup(destination)
if (committed) {
if (old.exists() && !old.deleteRecursively()) throw IOException("Cannot remove $old")
} else if (old.exists()) {
if (destination.exists() && !destination.deleteRecursively()) throw IOException("Cannot remove $destination during rollback")
if (!old.renameTo(destination)) throw IOException("Cannot restore $destination; recovery copy remains at $old")
} else if (state.getProperty("existed.$i") == "false" && !staging.exists()) {
if (destination.exists() && !destination.deleteRecursively()) throw IOException("Cannot roll back $destination")
}
}
if (!journal.delete()) throw IOException("Cannot remove transaction journal $journal")
}
fun replaceAll(replacements: List<Pair<File, File>>, journal: File): String? {
try {
recover(journal)
val state = Properties()
state.setProperty("count", replacements.size.toString())
replacements.forEachIndexed { i, (destination, staging) ->
val singleJournal = File(destination.parentFile, ".${destination.name}.replacement")
if (singleJournal != journal) recover(singleJournal)
val old = backup(destination)
// Recover installs made before transaction journals existed.
if (!destination.exists() && old.exists() && !old.renameTo(destination)) throw IOException("Cannot restore $destination from $old")
if (old.exists() && !old.deleteRecursively()) throw IOException("Cannot remove $old")
if (!staging.isDirectory) throw IOException("Missing staged folder $staging")
state.setProperty("destination.$i", destination.absolutePath)
state.setProperty("staging.$i", staging.absolutePath)
state.setProperty("existed.$i", destination.exists().toString())
}
writeJournal(journal, state)
try {
for ((destination, staging) in replacements) {
val old = backup(destination)
if (destination.exists() && !destination.renameTo(old)) throw IOException("Cannot replace $destination")
if (!staging.renameTo(destination)) throw IOException("Cannot install $destination")
}
state.setProperty("committed", "true")
writeJournal(journal, state)
} catch (failure: Exception) {
try {
recover(journal)
} catch (rollback: Exception) {
throw IOException("${failure.message}; rollback failed: ${rollback.message}. Keep $journal and the .replaced folders for recovery.", failure)
}
throw failure
}
// A committed journal makes cleanup retryable without undoing a successful install.
try {
recover(journal)
} catch (cleanup: Exception) {
return "The new files are installed, but cleanup must be retried: ${cleanup.message}"
}
return null
} catch (failure: Exception) {
return failure.message ?: "Could not replace the installed folders."
}
}
private fun writeJournal(journal: File, state: Properties) {
journal.parentFile?.mkdirs()
val temporary = File(journal.parentFile, "${journal.name}.tmp")
FileOutputStream(temporary).use { output ->
state.store(output, null)
output.fd.sync()
}
if (!temporary.renameTo(journal)) throw IOException("Cannot write transaction journal $journal")
}
}
@@ -1,14 +1,13 @@
package org.wiicompiled.quest.launcher
import android.util.Log
import android.content.Context
import java.io.File
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.BuildConfig
/** Checks and swaps shared by every [GameSetup] task. */
object GameFiles {
private const val TAG = "WiiCompiledLauncher"
val checks = DiscChecks(BuildConfig.DISC_GAME_ID, BuildConfig.DISC_DOL_SHA256, BuildConfig.DISC_REL_SHA256)
/**
@@ -28,22 +27,17 @@ object GameFiles {
* Moves [staging] to [destination], keeping the old destination until the new one is in
* place. Null on success, otherwise the message to show.
*/
fun replace(destination: File, staging: File): String? {
val replaced = File(destination.parentFile, "${destination.name}.replaced")
replaced.deleteRecursively()
if (destination.exists() && !destination.renameTo(replaced)) {
return "The existing ${destination.name} folder could not be replaced. Remove it, for example with " +
"adb shell rm -r ${destination.absolutePath}, and try again."
fun replace(destination: File, staging: File): String? = DirectoryReplacement.replaceAll(
listOf(destination to staging), File(destination.parentFile, ".${destination.name}.replacement"),
)
fun recover(context: Context) {
val games = File(context.filesDir, "game")
DirectoryReplacement.recover(File(games, ".import-transaction"))
for (parent in listOf(games, GameStorage.gameRoot(context))) {
parent.listFiles { file -> file.name.startsWith(".") && file.name.endsWith(".replacement") }
?.forEach { DirectoryReplacement.recover(it) }
}
if (!staging.renameTo(destination)) {
replaced.renameTo(destination)
return "The new files could not be moved into ${destination.absolutePath}."
}
// The new copy is in place; an old one that will not delete only costs space.
if (replaced.exists() && !replaced.deleteRecursively()) {
Log.w(TAG, "Could not remove ${replaced.absolutePath}")
}
return null
}
fun gigabytes(bytes: Long) = "%.1f GB".format(bytes / 1_000_000_000.0)
@@ -50,6 +50,12 @@ object GamePackageImport {
val libraryStaging = File(context.filesDir, "game/.importing")
val dataStaging = File(gameRoot, "DATA.importing")
val modStaging = File(gameRoot, "${GameStorage.MOD_DIRECTORY}.importing")
val journal = File(libraryStaging.parentFile, ".import-transaction")
try {
DirectoryReplacement.recover(journal)
} catch (failure: Exception) {
return "Cannot recover the previous import: ${failure.message}"
}
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
try {
libraryStaging.mkdirs()
@@ -127,20 +133,21 @@ object GamePackageImport {
}
File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!)
if (hasData) {
GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it }
}
if (hasMod) {
GameFiles.replace(GameStorage.modDirectory(context), modStaging)?.let { return it }
}
val libraryDir = GameLibrary.directory(context, profile)
libraryDir.parentFile?.mkdirs()
GameFiles.replace(libraryDir, libraryStaging)?.let { return it }
val replacements = buildList {
if (hasData) add(GameStorage.discDirectory(context) to dataStaging)
if (hasMod) add(GameStorage.modDirectory(context) to modStaging)
add(libraryDir to libraryStaging)
}
DirectoryReplacement.replaceAll(replacements, journal)?.let { return it }
Log.i(TAG, "Imported ${profile.id} built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
GameProfile.select(context, profile)
return null
} finally {
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
if (!journal.exists()) {
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
}
}
}
@@ -107,6 +107,7 @@ object GameSetup {
}
val finishing = { publish(State.Finishing(task)) }
try {
GameFiles.recover(context)
val failure = if (task == Task.BuildGame) {
val reporter = GameBuild.Reporter { permille, step, done, total ->
publish(State.Working(task, permille.toLong(), 1000, step, done, total))
@@ -0,0 +1,128 @@
package org.wiicompiled.quest.launcher
import java.io.File
import java.nio.file.Files
import java.util.Properties
import org.junit.Assert.*
import org.junit.Test
class DirectoryReplacementTest {
private fun tree(parent: File, name: String, contents: String): File = File(parent, name).apply {
mkdirs()
File(this, "contents").writeText(contents)
}
private fun interrupted(journal: File, pairs: List<Pair<File, File>>, existed: List<Boolean>, committed: Boolean = false) {
val state = Properties().apply {
setProperty("count", pairs.size.toString())
setProperty("committed", committed.toString())
pairs.forEachIndexed { i, (destination, staging) ->
setProperty("destination.$i", destination.absolutePath)
setProperty("staging.$i", staging.absolutePath)
setProperty("existed.$i", existed[i].toString())
}
}
journal.outputStream().use { state.store(it, null) }
}
@Test fun restoresLegacyBackupBeforeAnotherAttempt() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val destination = File(root, "DATA")
tree(root, "DATA.replaced", "old")
val result = DirectoryReplacement.replaceAll(listOf(destination to File(root, "missing")), File(root, "journal"))
assertNotNull(result)
assertEquals("old", File(destination, "contents").readText())
} finally { root.deleteRecursively() }
}
@Test fun rollsBackEveryFolderWhenALaterMoveFails() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val data = tree(root, "DATA", "old data")
val pack = tree(root, "pack", "old pack")
val stagedData = tree(root, "new-data", "new data")
// The nested staging folder is moved away with pack, forcing the second install to fail.
val stagedPack = tree(pack, "new-pack", "new pack")
val journal = File(root, "journal")
assertNotNull(DirectoryReplacement.replaceAll(listOf(data to stagedData, pack to stagedPack), journal))
assertEquals("old data", File(data, "contents").readText())
assertEquals("old pack", File(pack, "contents").readText())
assertFalse(journal.exists())
} finally { root.deleteRecursively() }
}
@Test fun interruptedImportRestoresAllOldFolders() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val data = tree(root, "DATA", "new data")
tree(root, "DATA.replaced", "old data")
val pack = File(root, "pack")
tree(root, "pack.replaced", "old pack")
val stagedPack = tree(root, "new-pack", "new pack")
val journal = File(root, "journal")
interrupted(journal, listOf(data to File(root, "new-data"), pack to stagedPack), listOf(true, true))
DirectoryReplacement.recover(journal)
assertEquals("old data", File(data, "contents").readText())
assertEquals("old pack", File(pack, "contents").readText())
assertTrue(stagedPack.exists())
assertFalse(journal.exists())
} finally { root.deleteRecursively() }
}
@Test fun interruptedFirstInstallRemovesOnlyPublishedFolders() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val data = tree(root, "DATA", "new data")
val journal = File(root, "journal")
interrupted(journal, listOf(data to File(root, "new-data")), listOf(false))
DirectoryReplacement.recover(journal)
assertFalse(data.exists())
} finally { root.deleteRecursively() }
}
@Test fun committedImportKeepsNewFoldersAndFinishesCleanup() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val data = tree(root, "DATA", "new data")
val old = tree(root, "DATA.replaced", "old data")
val journal = File(root, "journal")
interrupted(journal, listOf(data to File(root, "new-data")), listOf(true), committed = true)
DirectoryReplacement.recover(journal)
assertEquals("new data", File(data, "contents").readText())
assertFalse(old.exists())
assertFalse(journal.exists())
} finally { root.deleteRecursively() }
}
@Test fun importRecoversAnEarlierSingleFolderTransaction() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val data = tree(root, "DATA", "unfinished new data")
tree(root, "DATA.replaced", "old data")
val single = File(root, ".DATA.replacement")
interrupted(single, listOf(data to File(root, "previous-stage")), listOf(true))
val journal = File(root, "import-journal")
assertNotNull(DirectoryReplacement.replaceAll(listOf(data to File(root, "missing")), journal))
assertEquals("old data", File(data, "contents").readText())
assertFalse(single.exists())
assertFalse(journal.exists())
} finally { root.deleteRecursively() }
}
@Test fun successfulImportPublishesEverything() {
val root = Files.createTempDirectory("replacement").toFile()
try {
val data = tree(root, "DATA", "old data")
val library = tree(root, "library", "old library")
val stagedData = tree(root, "new-data", "new data")
val stagedLibrary = tree(root, "new-library", "new library")
val journal = File(root, "journal")
assertNull(DirectoryReplacement.replaceAll(listOf(data to stagedData, library to stagedLibrary), journal))
assertEquals("new data", File(data, "contents").readText())
assertEquals("new library", File(library, "contents").readText())
assertFalse(journal.exists())
assertFalse(File(root, "DATA.replaced").exists())
} finally { root.deleteRecursively() }
}
}
+8
View File
@@ -295,12 +295,20 @@ typedef struct {
bool xrHeadsetOnly;
} AuroraConfig;
typedef enum {
AURORA_INITIALIZATION_SUCCESS = 0,
AURORA_INITIALIZATION_GRAPHICS_UNAVAILABLE = 1,
} AuroraInitializationStatus;
typedef struct {
AuroraBackend backend;
const char* userPath;
const char* cachePath;
SDL_Window* window;
AuroraWindowSize windowSize;
AuroraInitializationStatus initializationStatus;
// On failure, owned by SDL on the calling thread. Copy before another SDL call.
const char* initializationError;
} AuroraInfo;
AuroraInfo aurora_initialize(int argc, char* argv[], const AuroraConfig* config);
+19 -1
View File
@@ -1229,15 +1229,23 @@ AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexce
const AuroraBackend requestedBackend = config.desiredBackend;
AuroraBackend selectedBackend = requestedBackend;
bool windowCreated = false;
std::string firstGraphicsError;
const auto rememberGraphicsError = [&] {
if (firstGraphicsError.empty() && SDL_GetError()[0] != '\0') {
firstGraphicsError = SDL_GetError();
}
};
if (selectedBackend != BACKEND_AUTO) {
Log.info("Requested graphics backend: {}", backend_name(selectedBackend));
if (window::create_window(selectedBackend)) {
if (webgpu::initialize(selectedBackend)) {
windowCreated = true;
} else {
rememberGraphicsError();
window::destroy_window();
}
} else {
rememberGraphicsError();
Log.error("Failed to create a window for backend {}: {}", backend_name(selectedBackend), SDL_GetError());
}
if (!windowCreated) {
@@ -1254,18 +1262,28 @@ AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexce
for (const auto backendType : PreferredBackendOrder) {
selectedBackend = backendType;
if (!window::create_window(selectedBackend)) {
rememberGraphicsError();
continue;
}
if (webgpu::initialize(selectedBackend)) {
windowCreated = true;
break;
} else {
rememberGraphicsError();
window::destroy_window();
}
}
}
ASSERT(windowCreated, "Error creating window: {}", SDL_GetError());
if (!windowCreated) {
if (firstGraphicsError.empty()) firstGraphicsError = "No supported graphics backend is available";
SDL_SetError("%s", firstGraphicsError.c_str());
Log.error("Graphics initialization failed: {}", firstGraphicsError);
return {
.initializationStatus = AURORA_INITIALIZATION_GRAPHICS_UNAVAILABLE,
.initializationError = SDL_GetError(),
};
}
if (requestedBackend != BACKEND_AUTO && selectedBackend != requestedBackend) {
Log.error(
"Graphics backend fallback in effect: video.graphics_api requested {}, "
+5 -4
View File
@@ -399,19 +399,20 @@ const char* PADGetNameForControllerIndex(const u32 idx) {
}
void PADSetPortForIndex(const u32 idx, const u32 port) {
if (port >= PAD_MAX_CONTROLLERS) return;
const auto* ctrl = __PADGetControllerForIndex(idx);
if (ctrl == nullptr) {
return;
}
const int32_t oldPort = SDL_GetGamepadPlayerIndex(ctrl->m_controller);
const int32_t oldPort = aurora::input::player_index(ctrl->m_index);
if (const auto* dest = aurora::input::get_controller_for_player(port); dest != nullptr && dest != ctrl) {
SDL_SetGamepadPlayerIndex(dest->m_controller, -1);
aurora::input::set_player_index(dest->m_index, -1);
}
if (oldPort >= 0 && oldPort != port) {
aurora::input::persist_controller_for_player(oldPort, nullptr);
}
SDL_SetGamepadPlayerIndex(ctrl->m_controller, static_cast<Sint32>(port));
aurora::input::set_player_index(ctrl->m_index, static_cast<Sint32>(port));
aurora::input::persist_controller_for_player(port, ctrl);
}
@@ -437,7 +438,7 @@ void PADClearPort(const u32 port) {
if (ctrl == nullptr) {
return;
}
SDL_SetGamepadPlayerIndex(ctrl->m_controller, -1);
aurora::input::set_player_index(ctrl->m_index, -1);
}
// Secondary bindings live only in memory; the runtime re-applies them from its
+27 -12
View File
@@ -7,11 +7,13 @@
#include <algorithm>
#include <atomic>
#include <optional>
#include <mutex>
namespace aurora::vi {
std::optional<GXRenderModeObj> g_renderMode;
namespace {
std::atomic<float> g_presentAspectCorrection{1.f};
std::mutex g_renderModeMutex;
float calculate_present_aspect_correction(const GXRenderModeObj& rm) noexcept {
if (rm.viWidth == 0 || rm.viHeight == 0) {
@@ -29,9 +31,8 @@ float calculate_present_aspect_correction(const GXRenderModeObj& rm) noexcept {
const float verticalFill = static_cast<float>(rm.viHeight) / nominalActiveHeight;
return horizontalFill / verticalFill;
}
} // namespace
Vec2<uint32_t> render_mode_size() noexcept {
Vec2<uint32_t> render_mode_size_locked() noexcept {
if (!g_renderMode) {
return {640, 528};
}
@@ -40,18 +41,31 @@ Vec2<uint32_t> render_mode_size() noexcept {
return {std::max<uint32_t>(g_renderMode->fbWidth, 640), std::max<uint32_t>(g_renderMode->efbHeight, 528)};
}
} // namespace
Vec2<uint32_t> render_mode_size() noexcept {
std::lock_guard lock(g_renderModeMutex);
return render_mode_size_locked();
}
void configure(const GXRenderModeObj* rm) noexcept {
const auto oldSize = render_mode_size();
if (rm == nullptr) {
g_renderMode.reset();
} else {
g_renderMode = *rm;
g_presentAspectCorrection.store(calculate_present_aspect_correction(*rm), std::memory_order_release);
bool sizeChanged = false;
{
std::lock_guard lock(g_renderModeMutex);
const auto oldSize = render_mode_size_locked();
if (rm == nullptr) {
g_renderMode.reset();
} else {
g_renderMode = *rm;
g_presentAspectCorrection.store(calculate_present_aspect_correction(*rm), std::memory_order_release);
}
if (rm == nullptr) {
g_presentAspectCorrection.store(1.f, std::memory_order_release);
}
sizeChanged = render_mode_size_locked() != oldSize;
}
if (rm == nullptr) {
g_presentAspectCorrection.store(1.f, std::memory_order_release);
}
if (render_mode_size() != oldSize) {
// Never hold the mode lock across a resize request or a renderer callback.
if (sizeChanged) {
window::request_frame_buffer_resize();
}
}
@@ -61,6 +75,7 @@ Vec2<uint32_t> configured_fb_size() noexcept {
}
Vec2<uint32_t> visible_fb_size() noexcept {
std::lock_guard lock(g_renderModeMutex);
if (!g_renderMode) {
return {640, 528};
}
+18 -19
View File
@@ -1,4 +1,5 @@
#include "common.hpp"
#include "staging_map.hpp"
#include "../gx/shader_info.hpp"
#include "clear.hpp"
@@ -139,12 +140,7 @@ wgpu::Buffer g_storageBuffer;
constexpr size_t FrameSlotCount = 3;
static std::array<wgpu::Buffer, FrameSlotCount> g_stagingBuffers;
static size_t currentStagingBuffer = 0;
enum class BufferMapState {
Unmapped,
Mapping,
Mapped,
};
static std::atomic s_mappingState{BufferMapState::Unmapped};
static StagingMapState s_mappingState;
static wgpu::Limits g_cachedLimits;
// Advanced once per logical frame in the seal prologue, under the renderer GPU mutex and with the
// producer blocked, so every later reader sees a value that no longer moves.
@@ -405,7 +401,7 @@ static size_t g_recordingSnapshotSlot = 0;
static TextureHandle new_resolve_source_snapshot(wgpu::Extent3D size, wgpu::TextureFormat format) noexcept {
const wgpu::TextureDescriptor textureDescriptor{
.label = "GX Copy Source Snapshot",
.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst,
.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopySrc | wgpu::TextureUsage::CopyDst,
.dimension = wgpu::TextureDimension::e2D,
.size = size,
.format = format,
@@ -1048,7 +1044,7 @@ void initialize() {
label.c_str());
}
currentStagingBuffer = 0;
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
s_mappingState.reset();
map_staging_buffer();
{
@@ -1160,6 +1156,8 @@ void shutdown() {
g_uniformBuffer = {};
g_indexBuffer = {};
g_storageBuffer = {};
// Invalidate outstanding callbacks before releasing their buffers.
s_mappingState.reset();
g_stagingBuffers.fill({});
for (auto& pool : g_resolveSourceSnapshotPools) {
pool.entry.reset();
@@ -1178,27 +1176,25 @@ void shutdown() {
g_inOffscreen = false;
g_frameIndex = UINT32_MAX;
currentStagingBuffer = 0;
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
}
void map_staging_buffer() {
auto expected = BufferMapState::Unmapped;
if (!s_mappingState.compare_exchange_strong(expected, BufferMapState::Mapping, std::memory_order_acq_rel,
std::memory_order_acquire)) {
const auto generation = s_mappingState.request();
if (generation == 0) {
return;
}
g_stagingBuffers[currentStagingBuffer].MapAsync(
wgpu::MapMode::Write, 0, StagingBufferSize, wgpu::CallbackMode::AllowSpontaneous,
[](wgpu::MapAsyncStatus status, wgpu::StringView message) {
[generation](wgpu::MapAsyncStatus status, wgpu::StringView message) {
const auto result = status == wgpu::MapAsyncStatus::Success ? BufferMapState::Mapped : BufferMapState::Unmapped;
if (!s_mappingState.complete(generation, result)) return;
if (status == wgpu::MapAsyncStatus::CallbackCancelled || status == wgpu::MapAsyncStatus::Aborted) {
Log.warn("Buffer mapping {}: {}", magic_enum::enum_name(status), message);
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
return;
}
ASSERT(status == wgpu::MapAsyncStatus::Success, "Buffer mapping failed: {} {}", magic_enum::enum_name(status),
message);
s_mappingState.store(BufferMapState::Mapped, std::memory_order_release);
});
}
@@ -1208,7 +1204,7 @@ static bool begin_frame_impl(bool clearEfb) {
ZoneScopedN("Wait for buffer map");
map_staging_buffer();
while (true) {
const auto mappingState = s_mappingState.load(std::memory_order_acquire);
const auto mappingState = s_mappingState.state();
if (mappingState == BufferMapState::Mapped) {
break;
}
@@ -1224,6 +1220,9 @@ static bool begin_frame_impl(bool clearEfb) {
return false;
}
g_instance.ProcessEvents();
webgpu::fail_if_device_lost();
// Sleep until the map callback lands (or 1 ms passes) instead of spinning a core on ProcessEvents.
s_mappingState.wait_for_progress();
}
}
g_recordingSnapshotSlot = currentStagingBuffer;
@@ -1296,12 +1295,12 @@ void abort_frame() noexcept {
g_textureUploads.clear();
g_textureUpload.release();
}
if (s_mappingState.load(std::memory_order_acquire) == BufferMapState::Mapped) {
if (s_mappingState.state() == BufferMapState::Mapped) {
// Pending interpolation tasks hold raw pointers into the mapped staging
// range; they must be dropped before the buffer is unmapped and rotated.
gx::drop_pending_frame_interpolation_uniforms();
g_stagingBuffers[currentStagingBuffer].Unmap();
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
s_mappingState.reset();
currentStagingBuffer = (currentStagingBuffer + 1) % g_stagingBuffers.size();
map_staging_buffer();
}
@@ -1741,7 +1740,7 @@ static bool end_batch_impl(const wgpu::CommandEncoder& cmd, bool advanceFrame,
g_uniformUploadDestination = nullptr;
}
g_stagingBuffers[currentStagingBuffer].Unmap();
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
s_mappingState.reset();
g_stats.drawCallCount = g_drawCallCount;
g_stats.mergedDrawCallCount = g_mergedDrawCallCount;
g_stats.lastVertSize = writeBuffer(g_verts, g_vertexBuffer, VertexBufferSize, "Vertex");
+2 -1
View File
@@ -109,8 +109,9 @@ void ensure_native_texture(PendingCopy& pending, TextureHandle* cache = nullptr)
*cache = pending.nativeTexture;
}
}
// The shared blit shader clamps Y to flags.z/w; preserve the full source.
const std::array nativeBlitUniform{
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 64.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 64.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f,
};
pending.nativeBlitUniform = push_uniform(nativeBlitUniform);
}
+3 -1
View File
@@ -426,6 +426,7 @@ static PendingPipeline* touch_pending_pipeline(PipelineRef hash, bool prioritize
g_priorityPipelines.emplace_back(std::move(*backgroundIt));
g_backgroundPipelines.erase(backgroundIt);
g_pipelineCv.notify_all();
return &g_priorityPipelines.back();
}
@@ -566,7 +567,8 @@ static PipelineRef find_pipeline_impl(ShaderType type, const PipelineConfig& con
}
if (notifyWorker) {
g_pipelineCv.notify_one();
// Compiler workers and renderer waiters share this condition variable.
g_pipelineCv.notify_all();
}
if (notifyWaiters) {
g_pipelineCv.notify_all();
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
namespace aurora::gfx {
enum class BufferMapState { Unmapped, Mapping, Mapped };
// The renderer owns request/reset; Dawn may complete a request on another thread.
// An old callback must never publish readiness for a different staging slot.
class StagingMapState {
mutable std::mutex mutex_;
std::condition_variable changed_;
uint64_t generation_ = 0;
BufferMapState state_ = BufferMapState::Unmapped;
public:
uint64_t request() {
std::lock_guard lock(mutex_);
if (state_ != BufferMapState::Unmapped) return 0;
state_ = BufferMapState::Mapping;
return ++generation_;
}
bool complete(uint64_t generation, BufferMapState state) {
{
std::lock_guard lock(mutex_);
if (generation != generation_ || state_ != BufferMapState::Mapping) return false;
state_ = state;
}
changed_.notify_all();
return true;
}
void reset() {
{
std::lock_guard lock(mutex_);
++generation_;
state_ = BufferMapState::Unmapped;
}
changed_.notify_all();
}
BufferMapState state() const {
std::lock_guard lock(mutex_);
return state_;
}
void wait_for_progress() {
std::unique_lock lock(mutex_);
// ProcessEvents is still serviced between waits for implementations that
// need it. A spontaneous completion wakes immediately, without polling.
changed_.wait_for(lock, std::chrono::milliseconds(1), [&] { return state_ != BufferMapState::Mapping; });
}
};
} // namespace aurora::gfx
+20 -6
View File
@@ -422,7 +422,7 @@ static wgpu::BindGroupLayout g_depthBindGroupLayout;
static wgpu::Sampler g_nearestSampler;
static wgpu::Sampler g_linearSampler;
static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines;
static wgpu::RenderPipeline g_blitPipeline;
static absl::flat_hash_map<wgpu::TextureFormat, wgpu::RenderPipeline> g_blitPipelines;
static wgpu::RenderPipeline g_quest1BlitPipeline;
static bool quest1_simple_blit() noexcept {
@@ -549,9 +549,15 @@ void initialize() {
};
g_depthBindGroupLayout = g_device.CreateBindGroupLayout(&depthBindGroupLayoutDescriptor);
g_blitPipeline = create_pipeline(
{GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"},
ShaderPreamble, g_bindGroupLayout);
// Native RAM readback uses RGBA even when the EFB/surface uses BGRA.
// Build both variants here; frame workers only read the completed map.
for (const auto format : {wgpu::TextureFormat::RGBA8Unorm, wgpu::TextureFormat::BGRA8Unorm,
webgpu::g_graphicsConfig.surfaceConfiguration.format}) {
if (format != wgpu::TextureFormat::Undefined && !g_blitPipelines.contains(format)) {
g_blitPipelines[format] = create_pipeline({GX_TF_RGBA8, FragPassthrough, format, "TexCopyConv Blit"},
ShaderPreamble, g_bindGroupLayout);
}
}
g_quest1BlitPipeline = create_pipeline(
{GX_TF_RGBA8, {}, webgpu::g_graphicsConfig.surfaceConfiguration.format, "Quest 1 Simple TexCopy Blit"},
SimpleBlitShader, g_bindGroupLayout);
@@ -585,7 +591,7 @@ void initialize() {
void shutdown() {
g_pipelines.clear();
g_blitPipeline = {};
g_blitPipelines.clear();
g_quest1BlitPipeline = {};
g_bindGroupLayout = {};
g_depthBindGroupLayout = {};
@@ -678,7 +684,15 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
}
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
execute(cmd, req, quest1_simple_blit() ? g_quest1BlitPipeline : g_blitPipeline);
if (quest1_simple_blit()) {
execute(cmd, req, g_quest1BlitPipeline);
return;
}
const auto it = g_blitPipelines.find(req.dst->format);
if (it == g_blitPipelines.end()) {
Log.fatal("Unsupported blit destination format {}", static_cast<int>(req.dst->format));
}
execute(cmd, req, it->second);
}
} // namespace aurora::gfx::tex_copy_conv
+40 -12
View File
@@ -33,10 +33,11 @@ using IndexBuffer = std::vector<u16>;
static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount) {
size_t writePos = 0;
if (prim == GX_QUADS) {
// Retain the existing incomplete-quad behavior: every started group emits a complete six-index quad.
buf.resize(((static_cast<u32>(vtxCount) + 3u) / 4u) * 6u);
// GX renders a three-vertex remainder as a triangle. One/two are ignored.
const u32 completeVertices = static_cast<u32>(vtxCount) & ~3u;
buf.resize((completeVertices / 4u) * 6u + (vtxCount % 4u == 3u ? 3u : 0u));
for (u16 v = 0; v < vtxCount; v += 4) {
for (u32 v = 0; v < completeVertices; v += 4) {
const u16 idx0 = v;
const u16 idx1 = static_cast<u16>(v + 1);
const u16 idx2 = static_cast<u16>(v + 2);
@@ -48,15 +49,21 @@ static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount
buf[writePos++] = idx3;
buf[writePos++] = idx0;
}
if (vtxCount % 4u == 3u) {
buf[writePos++] = static_cast<u16>(completeVertices);
buf[writePos++] = static_cast<u16>(completeVertices + 1u);
buf[writePos++] = static_cast<u16>(completeVertices + 2u);
}
} else if (prim == GX_TRIANGLES) {
buf.resize(vtxCount);
for (u16 v = 0; v < vtxCount; ++v) {
const u32 completeVertices = (static_cast<u32>(vtxCount) / 3u) * 3u;
buf.resize(completeVertices);
for (u32 v = 0; v < completeVertices; ++v) {
buf[writePos++] = v;
}
} else if (prim == GX_TRIANGLEFAN) {
const u32 indexCount = vtxCount <= 3 ? vtxCount : 3u + (static_cast<u32>(vtxCount) - 3u) * 3u;
const u32 indexCount = vtxCount < 3 ? 0u : (static_cast<u32>(vtxCount) - 2u) * 3u;
buf.resize(indexCount);
for (u16 v = 0; v < vtxCount; ++v) {
for (u32 v = 0; indexCount != 0 && v < vtxCount; ++v) {
if (v < 3) {
buf[writePos++] = v;
continue;
@@ -66,9 +73,9 @@ static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount
buf[writePos++] = v;
}
} else if (prim == GX_TRIANGLESTRIP) {
const u32 indexCount = vtxCount <= 3 ? vtxCount : 3u + (static_cast<u32>(vtxCount) - 3u) * 3u;
const u32 indexCount = vtxCount < 3 ? 0u : (static_cast<u32>(vtxCount) - 2u) * 3u;
buf.resize(indexCount);
for (u16 v = 0; v < vtxCount; ++v) {
for (u32 v = 0; indexCount != 0 && v < vtxCount; ++v) {
if (v < 3) {
buf[writePos++] = v;
continue;
@@ -91,6 +98,13 @@ static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount
return static_cast<u32>(writePos);
}
// Empty/incomplete draws consume FIFO bytes but cannot produce a primitive.
static bool has_complete_primitive(GXPrimitive prim, u16 count) {
if (prim == GX_POINTS) return count >= 1;
if (prim == GX_LINES || prim == GX_LINESTRIP) return count >= 2;
return count >= 3;
}
// GX FIFO opcodes - use CP_ prefix to avoid clashing with GXCommandList.h macros
static constexpr u8 CP_CMD_NOP = GX_NOP;
static constexpr u8 CP_CMD_LOAD_CP_REG = GX_LOAD_CP_REG;
@@ -552,6 +566,10 @@ void process(const u8* data, u32 size, bool bigEndian) {
for (int i = GX_VA_POS; i <= GX_VA_TEX7; ++i) {
g_gxState.arrays[i].cachedRange = {};
}
// A merged draw retains its previous array uploads. Force a new draw so
// handle_draw_unmerged observes the invalidation and uploads fresh data.
// Pipeline configuration itself did not change.
g_gxState.stateDirty = true;
break;
}
@@ -1926,6 +1944,10 @@ static u32 calculate_last_vtx_size(GXVtxFmt fmt) {
g_gxState.lastVtxFmt = fmt;
g_gxState.lastVtxSize = vtxSize;
// The format is selected by the draw opcode, without a register write.
// Even equal-stride formats may decode bytes differently, so do not merge
// into a draw using the previous format's shader and uniform layout.
g_gxState.stateDirty = true;
return vtxSize;
}
@@ -2366,6 +2388,8 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui
return false;
}
if (!has_complete_primitive(prim, vtxCount)) return true;
// This entry point bypasses process(), so it owns the renderer lock itself.
std::lock_guard gpuLock(aurora::renderer_gpu_mutex());
if (model_array_hidden()) return true;
@@ -2414,7 +2438,7 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
return false;
}
if (model_array_hidden()) {
if (!has_complete_primitive(prim, vtxCount) || model_array_hidden()) {
pos += totalVtxBytes;
return true;
}
@@ -2436,9 +2460,12 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
// Only if the previous draw call was a single instance draw (no lines/points handling), and only into a draw
// that resolved the same animated array: the merged whole renders through that draw's binding. Anything the
// decision cache cannot vouch for (a command it was not recorded against) stays unmerged.
// Expanded lines/points have different vertex interpretation even with one instance.
// Triangle-list output has no restart index; index 65535 is usable.
// Overflow would address earlier vertices instead of the appended geometry.
if (lastDraw != nullptr && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS &&
!lastDraw->uniformReplayLayout.vertexMotion.enabled &&
lastDraw->instanceCount == 1 &&
!lastDraw->uniformReplayLayout.vertexMotion.enabled && !lastDraw->expandedPrimitive &&
lastDraw->instanceCount == 1 && uint64_t(lastDraw->vtxCount) + vtxCount <= 65536u &&
(nativeWheelArrays.empty() ||
(nativeWheelLastDrawCommand == lastDraw && nativeWheelLastDecision == nativeWheel)))
LIKELY {
@@ -2615,6 +2642,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
.vtxCount = vtxCount,
.indexCount = numIndices,
.instanceCount = instanceCount,
.expandedPrimitive = prim == GX_LINES || prim == GX_LINESTRIP || prim == GX_POINTS,
.bindGroups = bindGroups,
.dstAlpha = pipelineState.dstAlpha,
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
+2 -1
View File
@@ -1910,7 +1910,8 @@ void populate_pipeline_config(PipelineConfig& config, GXPrimitive primitive, GXV
config.pixelFmt = g_gxState.pixelFmt;
config.dstAlpha = effective_dst_alpha(g_gxState.pixelFmt, g_gxState.alphaUpdate, g_gxState.dstAlpha);
config.depthCompare = g_gxState.depthCompare;
config.depthUpdate = g_gxState.depthUpdate;
// As on the hardware (and in Dolphin): with the Z compare off, the Z buffer isn't updated either.
config.depthUpdate = g_gxState.depthCompare && g_gxState.depthUpdate;
config.alphaUpdate = effective_alpha_update(g_gxState.pixelFmt, g_gxState.alphaUpdate);
config.colorUpdate = g_gxState.colorUpdate;
}
+2
View File
@@ -439,6 +439,8 @@ struct GXState {
u32 pipelineStateGeneration = next_gx_state_epoch();
std::array<u32, 0x100> bpRegCache = [] {
std::array<u32, 0x100> regs{};
// Force the first GEN_MODE decode without changing its masked reset value.
regs[0x00] = 0xFF000000;
regs[0xFE] = 0x00FFFFFF;
return regs;
}();
+1
View File
@@ -29,6 +29,7 @@ struct DrawData {
uint32_t vtxCount;
uint32_t indexCount;
uint32_t instanceCount;
bool expandedPrimitive;
GXBindGroups bindGroups;
uint32_t dstAlpha;
// Valid only for simple orthographic rectangles/lines (textured or not).
+16 -2
View File
@@ -1739,8 +1739,22 @@ fn load_u16(p: ptr<storage, array<u32>>, byte_off: u32, le: bool) -> u32 {{
return bswap16(raw, le);
}}
fn load_u24_raw(p: ptr<storage, array<u32>>, byte_off: u32) -> u32 {{
let word_idx = byte_off >> 2u;
let sub = byte_off & 3u;
let word = p[word_idx];
// Three bytes at offsets zero or one fit entirely in this word. Do not
// access the next word: this attribute may end at the binding boundary.
if (sub <= 1u) {{
return (word >> (sub * 8u)) & 0x00FFFFFFu;
}}
let next = p[word_idx + 1u];
let shift = sub * 8u;
return ((word >> shift) | (next << (32u - shift))) & 0x00FFFFFFu;
}}
fn load_u24(p: ptr<storage, array<u32>>, byte_off: u32, le: bool) -> u32 {{
let raw = load_u32_raw(p, byte_off) & 0x00FFFFFFu;
let raw = load_u24_raw(p, byte_off);
if (le) {{
return raw;
}}
@@ -1780,7 +1794,7 @@ fn raw_fetch_u8_2(p: ptr<storage, array<u32>>, byte_off: u32) -> vec2u {{
}}
fn raw_fetch_u8_3(p: ptr<storage, array<u32>>, byte_off: u32) -> vec3u {{
let raw = load_u32_raw(p, byte_off);
let raw = load_u24_raw(p, byte_off);
return vec3u(
extractBits(raw, 0u, 8u),
extractBits(raw, 8u, 8u),
+30 -6
View File
@@ -85,18 +85,30 @@ void initialize() noexcept {
void shutdown() noexcept {
ZoneScoped;
if (g_useSdlRenderer) {
ImGui_ImplSDLRenderer3_Shutdown();
} else {
ImGui_ImplWGPU_Shutdown();
// Startup can fail before either backend initializes. A context alone does
// not mean its renderer/platform backend owns resources to release.
if (ImGui::GetCurrentContext() != nullptr) {
ImGuiIO& io = ImGui::GetIO();
if (io.BackendRendererUserData != nullptr) {
if (g_useSdlRenderer) {
ImGui_ImplSDLRenderer3_Shutdown();
} else {
ImGui_ImplWGPU_Shutdown();
}
}
if (io.BackendPlatformUserData != nullptr) {
ImGui_ImplSDL3_Shutdown();
}
ImGui::DestroyContext();
}
ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext();
for (const auto& texture : g_sdlTextures) {
SDL_DestroyTexture(texture);
}
g_sdlTextures.clear();
g_wgpuTextures.clear();
g_useSdlRenderer = false;
g_scale = 0.f;
g_frameDataBuilt = true;
}
void process_event(const SDL_Event& event) noexcept {
@@ -209,9 +221,13 @@ void render(const wgpu::RenderPassEncoder& pass) noexcept {
ImGui_ImplSDLRenderer3_RenderDrawData(data, renderer);
SDL_RenderPresent(renderer);
} else {
#ifdef AURORA_GFX_DEBUG_GROUPS
pass.PushDebugGroup("Aurora: Dear Imgui");
#endif
ImGui_ImplWGPU_RenderDrawData(data, pass.Get());
#ifdef AURORA_GFX_DEBUG_GROUPS
pass.PopDebugGroup();
#endif
}
}
@@ -275,9 +291,13 @@ void render(const wgpu::RenderPassEncoder& pass, const ImDrawData* data) noexcep
if (g_useSdlRenderer || data == nullptr) {
return;
}
#ifdef AURORA_GFX_DEBUG_GROUPS
pass.PushDebugGroup("Aurora: Dear Imgui");
#endif
ImGui_ImplWGPU_RenderDrawData(const_cast<ImDrawData*>(data), pass.Get());
#ifdef AURORA_GFX_DEBUG_GROUPS
pass.PopDebugGroup();
#endif
}
StereoOverlay latch_stereo_overlay() noexcept {
@@ -291,9 +311,13 @@ bool render_draw_data(const wgpu::RenderPassEncoder& pass, ImDrawData* data) noe
if (g_useSdlRenderer || data == nullptr || ImGui::GetCurrentContext() == nullptr) {
return false;
}
#ifdef AURORA_GFX_DEBUG_GROUPS
pass.PushDebugGroup("Aurora: Dear Imgui headset panel");
#endif
ImGui_ImplWGPU_RenderDrawData(data, pass.Get());
#ifdef AURORA_GFX_DEBUG_GROUPS
pass.PopDebugGroup();
#endif
return true;
}
+23
View File
@@ -414,6 +414,29 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept {
g_GameControllers[instance] = controller;
ensure_player_index(g_GameControllers[instance]);
apply_port_preferences();
#if defined(SDL_PLATFORM_MACOS)
// First-use convenience only: never override a saved assignment or None.
if (g_portPreferences[0].state == PortPreferenceState::Unset) {
bool hasOtherPortPreference = false;
for (size_t port = 1; port < g_portPreferences.size(); ++port) {
if (g_portPreferences[port].state == PortPreferenceState::Controller &&
identity_match(g_portPreferences[port].identity, controller_identity(g_GameControllers[instance])) !=
IdentityMatch::None) {
hasOtherPortPreference = true;
break;
}
}
if (!hasOtherPortPreference) {
const auto* p0 = get_controller_for_player(0);
if (p0 == nullptr) {
assign_player_index(g_GameControllers[instance], 0);
persist_controller_for_player(0, &g_GameControllers[instance]);
} else if (p0 == &g_GameControllers[instance]) {
persist_controller_for_player(0, &g_GameControllers[instance]);
}
}
}
#endif
return instance;
}
+9
View File
@@ -627,12 +627,16 @@ bool initialize(AuroraBackend auroraBackend) {
g_adapter = std::move(adapter);
} else {
Log.warn("Adapter request failed: {}", message);
const std::string_view reason{message};
SDL_SetError("Graphics adapter unavailable: %.*s",
static_cast<int>(std::min<size_t>(reason.size(), 512)), reason.data());
}
});
const auto status = g_instance.WaitAny(future, 5000000000);
if (status != wgpu::WaitStatus::Success) {
Log.error("Failed to create {} adapter: {}", magic_enum::enum_name(backend),
magic_enum::enum_name(status));
SDL_SetError("Graphics adapter request did not complete within its startup deadline");
return false;
}
if (!g_adapter) {
@@ -845,11 +849,16 @@ bool initialize(AuroraBackend auroraBackend) {
g_device = std::move(device);
} else {
Log.warn("Device request failed: {}", message);
const std::string_view reason{message};
SDL_SetError("Graphics device unavailable: %.*s",
static_cast<int>(std::min<size_t>(reason.size(), 512)),
reason.data());
}
});
const auto status = g_instance.WaitAny(future, 5000000000);
if (status != wgpu::WaitStatus::Success) {
Log.error("Failed to create device: {}", magic_enum::enum_name(status));
SDL_SetError("Graphics device request did not complete within its startup deadline");
return false;
}
if (!g_device) {
+28 -2
View File
@@ -6,6 +6,7 @@
#include <deque>
#include <memory>
#include <mutex>
#include <limits>
#include <string>
#include <filesystem>
#include <thread>
@@ -313,8 +314,33 @@ static size_t load_from_database(const XXH128_hash_t& keyHash, void* value, size
if (ret == SQLITE_ROW) {
// Hit
const auto foundPtr = sqlite3_column_blob(load_stmt, 0);
foundSize = sqlite3_column_int64(load_stmt, 1);
const bool compressed = sqlite3_column_int(load_stmt, 2) != 0;
const auto declaredSize = sqlite3_column_int64(load_stmt, 1);
const auto storedSize = sqlite3_column_bytes(load_stmt, 0);
const auto compression = sqlite3_column_int(load_stmt, 2);
const bool compressed = compression == 1;
// Dawn asks for the size before allocating its destination. Validate here,
// not only during the copy: corrupt metadata must become a cache miss.
bool valid = declaredSize > 0 &&
static_cast<uint64_t>(declaredSize) <= std::numeric_limits<size_t>::max() &&
foundPtr != nullptr && storedSize > 0 && (compression == 0 || compression == 1);
if (valid && compressed) {
#if defined(AURORA_CACHE_USE_ZSTD)
// Our writer uses ZSTD_compress, which records the original content size.
const auto frameSize = ZSTD_getFrameContentSize(foundPtr, static_cast<size_t>(storedSize));
valid = frameSize != ZSTD_CONTENTSIZE_ERROR && frameSize != ZSTD_CONTENTSIZE_UNKNOWN &&
frameSize == static_cast<uint64_t>(declaredSize);
#else
valid = false;
#endif
} else if (valid) {
valid = declaredSize == storedSize;
}
if (!valid) {
Log.error("Ignoring cache entry with inconsistent size or compression metadata");
check(sqlite3_reset(load_stmt));
return 0;
}
foundSize = static_cast<size_t>(declaredSize);
if (value == nullptr) {
g_hits.fetch_add(1, std::memory_order_relaxed);
} else {
+1
View File
@@ -64,6 +64,7 @@ if (AURORA_ENABLE_GX)
add_executable(gx_fifo_tests
gx_fifo_test.cpp
gx_test_stubs.cpp
renderer_regression_test.cpp
stereo_replay_test.cpp
stereo_interpolation_test.cpp
stereo_mirror_test.cpp
+72 -3
View File
@@ -1328,12 +1328,35 @@ TEST(TevRegisterLivenessContract, PacksOneUniformWhenBothHalvesNeedInitialValue)
auto config = baseline;
config.tevStages[0].colorPass.a = GX_CC_C0;
config.tevStages[0].alphaPass.a = GX_CA_A0;
config.tevStages[0].colorPass.b = GX_CC_KONST;
config.tevStages[0].kcSel = GX_TEV_KCSEL_K0;
const auto baselineInfo = aurora::gx::build_shader_info(baseline);
const auto info = aurora::gx::build_shader_info(config);
EXPECT_TRUE(info.loadsTevRegRgb.test(GX_TEVREG0));
EXPECT_TRUE(info.loadsTevRegAlpha.test(GX_TEVREG0));
EXPECT_EQ(info.uniformSize, baselineInfo.uniformSize + sizeof(aurora::Vec4<float>));
// The final allocation is alignment-rounded, so adding one register need
// not increase it. Verify actual packing with a distinct following K color.
const auto savedReg = g_gxState.colorRegs[GX_TEVREG0];
const auto savedKColor = g_gxState.kcolors[GX_KCOLOR0];
g_gxState.colorRegs[GX_TEVREG0] = {11.f, 22.f, 33.f, 44.f};
g_gxState.kcolors[GX_KCOLOR0] = {55.f, 66.f, 77.f, 88.f};
EXPECT_TRUE(info.sampledKColors.test(GX_KCOLOR0));
aurora::gfx::testing::reset_uniform_allocations();
aurora::gx::build_uniform(info, 0, {}, {}, false);
const auto expectedReg = g_gxState.colorRegs[GX_TEVREG0];
const auto expectedKColor = g_gxState.kcolors[GX_KCOLOR0];
g_gxState.colorRegs[GX_TEVREG0] = savedReg;
g_gxState.kcolors[GX_KCOLOR0] = savedKColor;
const auto& bytes = aurora::gfx::testing::uniform_allocation(0);
const auto* reg = reinterpret_cast<const uint8_t*>(&expectedReg);
const auto found = std::search(bytes.begin(), bytes.end(), reg, reg + sizeof(aurora::Vec4<float>));
ASSERT_NE(found, bytes.end());
const size_t offset = static_cast<size_t>(found - bytes.begin());
ASSERT_LE(offset + 2 * sizeof(aurora::Vec4<float>), bytes.size());
EXPECT_EQ(std::memcmp(bytes.data() + offset + sizeof(aurora::Vec4<float>), &expectedKColor,
sizeof(aurora::Vec4<float>)),
0);
aurora::gfx::testing::reset_uniform_allocations();
}
// BP registers (direct FIFO writes, no dirty state flush needed)
@@ -1390,6 +1413,51 @@ TEST_F(GXFifoTest, BlendMode_Logic) {
EXPECT_EQ(g_gxState.blendOp, GX_LO_XOR);
}
TEST_F(GXFifoTest, GenMode_FirstZeroWriteDecodesAndRepeatDeduplicates) {
reset_gx_state();
const auto before = g_gxState.pipelineStateGeneration;
decode_fifo(bp_cmd(0, 0));
EXPECT_EQ(g_gxState.numTevStages, 1u);
EXPECT_EQ(g_gxState.cullMode, GX_CULL_NONE);
EXPECT_EQ(g_gxState.numChans, 0u);
EXPECT_EQ(g_gxState.numTexGens, 0u);
EXPECT_EQ(g_gxState.numIndStages, 0u);
EXPECT_EQ(g_gxState.bpRegCache[0], 0u);
EXPECT_NE(g_gxState.pipelineStateGeneration, before);
const auto decoded = g_gxState.pipelineStateGeneration;
decode_fifo(bp_cmd(0, 0));
EXPECT_EQ(g_gxState.pipelineStateGeneration, decoded);
}
TEST_F(GXFifoTest, GenMode_FirstMaskedWritePreservesZeroResetBits) {
for (const u32 mask : {0u, 1u << 10}) {
reset_gx_state();
const auto before = g_gxState.pipelineStateGeneration;
decode_fifo(bp_cmd(0xFE, mask));
decode_fifo(bp_cmd(0, 0xFFFFFF));
EXPECT_EQ(g_gxState.bpRegCache[0], mask);
EXPECT_EQ(g_gxState.bpRegCache[0xFE], 0xFFFFFFu);
EXPECT_EQ(g_gxState.numTevStages, mask ? 2u : 1u);
EXPECT_EQ(g_gxState.cullMode, GX_CULL_NONE);
EXPECT_NE(g_gxState.pipelineStateGeneration, before);
decode_fifo(bp_cmd(0, 0));
EXPECT_EQ(g_gxState.numTevStages, 1u);
EXPECT_EQ(g_gxState.bpRegCache[0], 0u);
}
}
TEST_F(GXFifoTest, GenMode_ColdSingleStageApiSetupDecodes) {
reset_gx_state();
GXSetNumTevStages(1);
GXSetNumTexGens(0);
GXSetNumChans(0);
GXSetCullMode(GX_CULL_NONE);
const auto bytes = flush_and_capture();
decode_fifo(bytes);
EXPECT_EQ(g_gxState.numTevStages, 1u);
EXPECT_EQ(g_gxState.cullMode, GX_CULL_NONE);
}
TEST_F(GXFifoTest, BpMask_AppliesOnlyToNextWrite) {
std::vector<u8> bytes;
auto mask = bp_cmd(0xFE, 1u << 19);
@@ -2861,6 +2929,7 @@ TEST_F(GXFifoTest, DrawTopologyTemplatesPreserveExactGxIndexOrder) {
const auto decodeAndReadIndices = [&](GXPrimitive primitive, u16 count) {
std::vector<u8> fifo;
append_test_draw(fifo, primitive, count);
aurora::gfx::testing::reset_vertex_push_record();
decode_fifo(fifo);
return aurora::gfx::testing::last_pushed_indices();
};
@@ -2871,7 +2940,7 @@ TEST_F(GXFifoTest, DrawTopologyTemplatesPreserveExactGxIndexOrder) {
g_gxState.stateDirty = true;
EXPECT_EQ(decodeAndReadIndices(GX_TRIANGLEFAN, 5), (std::vector<u16>{0, 1, 2, 0, 2, 3, 0, 3, 4}));
g_gxState.stateDirty = true;
EXPECT_EQ(decodeAndReadIndices(GX_TRIANGLEFAN, 2), (std::vector<u16>{0, 1}));
EXPECT_EQ(decodeAndReadIndices(GX_TRIANGLEFAN, 2), (std::vector<u16>{}));
g_gxState.stateDirty = true;
EXPECT_EQ(decodeAndReadIndices(GX_TRIANGLESTRIP, 6), (std::vector<u16>{0, 1, 2, 2, 1, 3, 2, 3, 4, 4, 3, 5}));
g_gxState.stateDirty = true;
@@ -0,0 +1,119 @@
#include "gx_test_common.hpp"
#include "gfx/staging_map.hpp"
#include "gx/pipeline.hpp"
#include <thread>
using aurora::gx::g_gxState;
namespace {
std::vector<u8> draw(GXPrimitive primitive, u16 count, GXVtxFmt format = GX_VTXFMT0) {
std::vector<u8> bytes{static_cast<u8>(primitive | format), static_cast<u8>(count >> 8), static_cast<u8>(count)};
bytes.resize(3 + count);
return bytes;
}
} // namespace
TEST_F(GXFifoTest, MaximumQuadCountTerminatesWithoutOutOfRangeIndices) {
g_gxState.lastVtxFmt = GX_VTXFMT0;
g_gxState.lastVtxSize = 1;
for (const u16 count : {65532, 65533, 65534, 65535}) {
g_gxState.stateDirty = true;
decode_fifo(draw(GX_QUADS, count));
const auto& indices = aurora::gfx::testing::last_pushed_indices();
ASSERT_EQ(indices.size(), (count / 4) * 6 + (count % 4 == 3 ? 3 : 0));
for (const auto index : indices)
ASSERT_LT(index, count);
}
}
TEST_F(GXFifoTest, IncompletePrimitivesNeverJoinAcrossDraws) {
g_gxState.lastVtxFmt = GX_VTXFMT0;
g_gxState.lastVtxSize = 1;
aurora::gfx::testing::use_draw_command_tracking(true);
decode_fifo(draw(GX_TRIANGLES, 4));
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{0, 1, 2}));
decode_fifo(draw(GX_TRIANGLES, 5));
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{4, 5, 6}));
const auto before = aurora::gfx::testing::last_pushed_indices();
decode_fifo(draw(GX_TRIANGLEFAN, 2));
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), before);
}
TEST_F(GXFifoTest, MergeStopsBeforeSixteenBitIndexOverflow) {
g_gxState.lastVtxFmt = GX_VTXFMT0;
g_gxState.lastVtxSize = 1;
aurora::gfx::testing::use_draw_command_tracking(true);
decode_fifo(draw(GX_TRIANGLES, 65535));
decode_fifo(draw(GX_TRIANGLES, 3));
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{0, 1, 2}));
}
TEST_F(GXFifoTest, VertexCacheInvalidationBreaksDrawMerging) {
g_gxState.lastVtxFmt = GX_VTXFMT0;
g_gxState.lastVtxSize = 1;
aurora::gfx::testing::use_draw_command_tracking(true);
decode_fifo(draw(GX_TRIANGLES, 3));
decode_fifo({GX_CMD_INVL_VC});
EXPECT_TRUE(g_gxState.stateDirty);
decode_fifo(draw(GX_TRIANGLES, 3));
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
}
TEST_F(GXFifoTest, EqualStrideVertexFormatChangeBreaksDrawMerging) {
aurora::gfx::testing::use_real_vertex_format_helpers(true);
g_gxState.vtxDesc[GX_VA_POS] = GX_DIRECT;
for (const auto format : {GX_VTXFMT0, GX_VTXFMT1}) {
g_gxState.vtxFmts[format].attrs[GX_VA_POS].cnt = GX_POS_XY;
g_gxState.vtxFmts[format].attrs[GX_VA_POS].type = GX_U8;
}
g_gxState.vtxFmts[GX_VTXFMT1].attrs[GX_VA_POS].frac = 1;
aurora::gfx::testing::use_draw_command_tracking(true);
for (const auto format : {GX_VTXFMT0, GX_VTXFMT1}) {
auto bytes = draw(GX_TRIANGLES, 3, format);
bytes.resize(9);
decode_fifo(bytes);
}
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
}
TEST_F(GXFifoTest, SingleExpandedPrimitiveCannotMergeWithTriangles) {
g_gxState.lastVtxFmt = GX_VTXFMT0;
g_gxState.lastVtxSize = 1;
aurora::gfx::testing::use_draw_command_tracking(true);
decode_fifo(draw(GX_POINTS, 1));
decode_fifo(draw(GX_TRIANGLES, 3));
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{0, 1, 2}));
}
TEST(StagingMapping, RetiredCallbacksCannotPublishAnotherBuffersReadiness) {
using namespace aurora::gfx;
StagingMapState state;
const auto old = state.request();
EXPECT_EQ(state.request(), 0u);
state.reset();
const auto current = state.request();
EXPECT_FALSE(state.complete(old, BufferMapState::Mapped));
EXPECT_FALSE(state.complete(old, BufferMapState::Unmapped));
EXPECT_EQ(state.state(), BufferMapState::Mapping);
EXPECT_TRUE(state.complete(current, BufferMapState::Mapped));
EXPECT_FALSE(state.complete(current, BufferMapState::Unmapped));
EXPECT_EQ(state.state(), BufferMapState::Mapped);
}
TEST(StagingMapping, AsyncCompletionWakesWaiters) {
using namespace aurora::gfx;
StagingMapState state;
const auto generation = state.request();
std::thread callback([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
state.complete(generation, BufferMapState::Mapped);
});
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (state.state() == BufferMapState::Mapping && std::chrono::steady_clock::now() < deadline)
state.wait_for_progress();
callback.join();
EXPECT_EQ(state.state(), BufferMapState::Mapped);
}
+2 -2
View File
@@ -823,7 +823,7 @@ Android facts this design rests on, all measured on a Quest 3:
application ID and storage, but select the appropriate CPU baseline (one
`headsetCpus` map in `app/build.gradle.kts`), manifest and launcher
behavior. `steamFrame` is Valve's Steam Frame under Lepton, SteamOS's
Android layer, where it cannot show a picture; see the README, The Android flavour.
Android layer, where it cannot show a picture; see the README, How it works.
`app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a
subdirectory with those Android choices and builds both game kit probes
(the Retro Rewind one only when the translation includes the mod), which
@@ -852,7 +852,7 @@ powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset quest1
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output with --retro-wfc-payload)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Headset quest1 -Install # game package from the Quest 1 kit
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset frame # Steam Frame flavour (README, The Android flavour)
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset frame # Steam Frame flavour (README, How it works)
adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
```
+81
View File
@@ -0,0 +1,81 @@
The third beta of WiiCompiled VR for the Steam Frame: Mario Kart Wii, statically recompiled to
native ARM64 code, running in VR on SteamOS through SteamVR. This one brings in fixes from upstream
WiiCompiled and from other WiiCompiled forks, Retro Rewind in the installer, and a diagnostics
script. Thanks to everyone whose work is in it; [`CREDITS.md`](https://github.com/mitch030504/Wiicompiled_VR_Frame/blob/openxr-work/CREDITS.md) lists who wrote
what.
**This release is source only.** The game is always built from your own clean PAL `RMCP01` disc,
and nothing built from it may be distributed, so there is no ready-built game here.
## New
- **Retro Rewind in the installer.** `steam-frame-install.sh --retro-rewind` downloads the Retro
Rewind pack from its own update server, builds Retro Rewind beside the base game and adds it to
your Steam library. `--retro-rewind-pack DIR` uses a pack you already have. Online play works on
Linux now that TLS does (below). From heurazy's Wiicompiled_VR-PLUS.
- **Adaptive resolution** (`[vr] adaptive_resolution`, off by default): lowers the race's eye
resolution down to 70% while frames fall behind, and raises it again once they keep up.
Experimental and not yet tried in a race on the Frame. From Wiicompiled_VR-PLUS.
- **Updating, from the PC or from inside the game.** `steam-frame-install.sh --update` reuses the
options the install was made with and builds nothing when the Frame already has the newest
release (and Retro Rewind pack); `--check` only reports. An install built on the Frame itself
(`--frame local`) gets an **Updates** tab in the settings panel: it checks for a new release and
builds it in the background while the game stays open, showing each step and how far the build
is. Close the game meanwhile and the update reopens it when done. Steam notifications were tried
for this and do not show on the Frame.
- **`Launcher/frame-diagnostics.sh`** gathers the logs, crash files, settings, SteamVR logs and
system state from the Frame into one archive, with a summary of how far startup got. Attach it
to bug reports.
## Fixed
- A crash while a scene loads (`GXClearVtxDesc`). From Wiicompiled_VR-PLUS.
- TLS on Linux, through Mbed TLS, so online services can connect at all (upstream #144), and a
timed-out TLS write no longer frees a session the game still holds (DarthMDev).
- Saves on a different drive or the SD card can be moved (upstream #212); the game keeps its local
Wii identity services while offline, and emptied WiiConnect24 files are refilled, so it no longer
reports a save error for them (upstream #244, nx-mod).
- `Config.toml` is saved through a temporary file, so a crash mid-save can't empty it
(BlackAndBlue95).
- Renderer fixes from upstream's KartPad batch (#244): draws no longer read past their own vertices,
three-byte vertex attributes, GEN_MODE, EFB copies and readback, pipeline cache rows and pipeline
compile wake-ups. Waiting on the GPU now sleeps instead of holding a CPU core busy.
- Effects no longer vanish while the world is darkened, such as Stormship's lightning
(BlackAndBlue95).
- Controller player indices and socket errors (upstream #251), and a `bltl` translation (#254).
## Faster
- Disc reads and SZS decompression take bulk copies and keep file handles open per thread
(KartPad).
- Audio mixing uses NEON (rooklz).
- Translated memory accesses no longer check the page size each time, since the Frame uses 4 KiB
pages; the game checks once at startup (from nx-mod's finding).
## Updating from frame-beta-2
Run the install command again; you can leave out `--disc`. Dawn's patches did not change, so it is
not rebuilt. Most of the game is, since the translator changed as well as the runtime and renderer,
so expect a longer update than usual. The build now also downloads Mbed TLS. Add `--retro-rewind` to get Retro Rewind too.
frame-beta-2's script has no `--update`, so this one update is the full install command; it saves
its options, and from then on `steam-frame-install.sh --update` is enough. To update from inside
the game later, make this install on the Frame itself with `--frame local`.
## Known issues
Unchanged from frame-beta-2:
- Images double in races and on the HUD, sometimes in the right eye only. Foveation is the main
suspect; try foveation Off if it bothers you, and report the result.
- Foveation follows the right eye less well than the left.
- VR frame interpolation is not recommended on the Frame.
- The Android (`steamFrame`) flavour cannot show a picture in Lepton; use the native build.
## Recommended settings
`render_scale = 1.25`, `foveation = "medium"`, `repeat_frames = true`, `frame_interpolation_fps = 0`
under `[vr]`, and `resolution_multiplier = 2` under `[video]`. SteamVR at 120 Hz.
Reports with the run log from `~/.local/share/WiiCompiled/Logs/`, or the archive
`frame-diagnostics.sh` makes, are welcome as issues.
+59
View File
@@ -0,0 +1,59 @@
The fourth beta of WiiCompiled VR for the Steam Frame focuses on safer installs and updates.
It preserves working disc data when extraction fails, fixes moving an installation to a different
path, and makes release updates remove obsolete source files without deleting disc data or build
caches.
**This release is source only.** Build the game from your own clean PAL `RMCP01` disc. No compiled
game or disc assets are included.
## Fixed
- Linux setup extracts and validates disc data in staging before replacing the working copy.
Failed validation or cancellation leaves the previous disc data intact, and cancellation stops
the extraction process before its scratch directory is removed.
- Frame installations keep remote paths with spaces and shell characters as single arguments.
- Changing the disc or Retro Rewind location updates the corresponding `Config.toml` setting
while preserving other settings.
- Release updates remove files owned by the previous release that no longer exist in the new
one. Disc assets, generated output and build caches remain in place.
- A work-directory lock prevents overlapping manual installs and in-game updates.
- Linux and Frame Retro Rewind builds verify the Retro-WFC payload signature before translation.
Android already performed this verification.
- Android folder replacements keep recovery copies until the full import commits. Interrupted
imports can roll back disc data, the mod pack and the compiled library together. Play is blocked
while a replacement journal is pending, and setup recovers it before another task starts.
## Updating from frame-beta-3
Fetch the new installer so this update uses the fixes:
```bash
curl -fsSL https://raw.githubusercontent.com/mitch030504/Wiicompiled_VR_Frame/openxr-work/Launcher/steam-frame-install.sh \
| bash -s -- --update
```
Add `--work-dir DIR` if the original install used a different work directory. The installer now
requires Python 3 and `flock` from util-linux on the build machine. Both are normally available on
the Frame.
The first update of an older installation downloads its previous release's archive once to recover
which source files it owned. This extra download prevents cleanup from deleting user data or build
caches. Later updates reuse the saved file list.
## Validation
- 664 .NET tests passed locally using .NET 10 major-version roll-forward; CI uses .NET 8.
- Seven Android directory-transaction tests and six Frame installer regression checks passed.
- Linux setup built successfully, and installer shell syntax checks passed.
- New Linux CI checks cover extraction failure, cancellation, recovery and Frame file operations.
A full APK build and real-headset installation were not tested for this release.
## Known issues
The VR rendering issues from frame-beta-3 remain: doubled images in races and on the HUD, weaker
right-eye foveation tracking, and unrecommended frame interpolation. The Android Steam Frame flavour
still does not show a picture in Lepton; use the native build.
Recommended settings remain `render_scale = 1.25`, `foveation = "medium"`, `repeat_frames = true`,
`frame_interpolation_fps = 0`, and `resolution_multiplier = 2`, with SteamVR at 120 Hz.
+43
View File
@@ -144,6 +144,43 @@ if(NOT MKW_NATIVE_PREBUILT_DIR)
set_target_properties(mkw_cryptopp PROPERTIES UNITY_BUILD OFF)
endif()
# TLS for non-Windows guest network HLE (runtime/src/hle/net/network_ssl.cpp) - the Windows path
# uses Schannel (a Windows-only OS API), which has no equivalent on Linux/Android, so this project
# needs its own TLS library there. mbed TLS was chosen over OpenSSL specifically because it cross-
# compiles cleanly for Android with nothing beyond a plain C toolchain (no perl/asm build-script
# dependency the way OpenSSL's build has), matching how this project already prefers toolchain-
# simple libraries (see Crypto++ above, similarly stripped of ASM/SIMD for portability).
# Fetched at build time from a pinned upstream release tarball with a checked SHA-256, the same way
# aurora-main's own dependencies (SDL, zlib, etc.) are pulled in - not committed as a vendored
# source tree, so the repository ships the compiled dependency rather than ~280 tracked upstream
# files. Bump MKW_MBEDTLS_VERSION/MKW_MBEDTLS_SHA256 together when updating; the hash comes from
# upstream's own signed `mbedtls-<version>-sha256sum.txt` release asset.
#
# The alias exists on every platform so the link lines in cmake/PublicProducts.cmake stay
# platform-independent, but it is only populated where network_ssl.cpp actually compiles the mbed
# TLS path (`#ifndef _WIN32`). Windows keeps Schannel and must not fetch anything: its builds run
# with FETCHCONTENT_FULLY_DISCONNECTED=ON against the offline dependency set prepared by
# Launcher/Prepare-Dependencies.ps1, so an unconditional fetch would fail a clean configure there
# and would also add a dependency Windows never links.
add_library(mkw_mbedtls INTERFACE)
add_library(mkw::mbedtls ALIAS mkw_mbedtls)
if(NOT MKW_PLATFORM_WINDOWS)
include(FetchContent)
set(MKW_MBEDTLS_VERSION "3.6.7")
set(MKW_MBEDTLS_SHA256 "a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6")
FetchContent_Declare(mkw_mbedtls_upstream
URL "https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-${MKW_MBEDTLS_VERSION}/mbedtls-${MKW_MBEDTLS_VERSION}.tar.bz2"
URL_HASH SHA256=${MKW_MBEDTLS_SHA256})
# Subproject mode already defaults ENABLE_TESTING off and skips codegen (GEN_FILES), but
# ENABLE_PROGRAMS defaults on and installation/package-config isn't wanted for a linked-in copy.
set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE)
set(ENABLE_TESTING OFF CACHE BOOL "" FORCE)
set(MBEDTLS_FATAL_WARNINGS OFF CACHE BOOL "" FORCE)
set(DISABLE_PACKAGE_CONFIG_AND_INSTALL ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(mkw_mbedtls_upstream)
target_link_libraries(mkw_mbedtls INTERFACE MbedTLS::mbedtls MbedTLS::mbedx509 MbedTLS::mbedcrypto)
endif()
set(MKW_TRANSLATED_COMPILE_JOBS 0 CACHE STRING
"Cap on concurrently compiling translated shard TUs via a Ninja job pool (0 = uncapped). \
Scheduling only - never affects output bytes, so it is deliberately outside the canonical flag fingerprint.")
@@ -469,6 +506,12 @@ target_include_directories(mkw_vr_eye_gaze_tests PRIVATE "${CMAKE_CURRENT_LIST_D
target_compile_features(mkw_vr_eye_gaze_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_eye_gaze_tests COMMAND mkw_vr_eye_gaze_tests)
# The AX mix kernels' AVX2 and NEON forms against their scalar reference loops.
add_executable(mkw_ax_mix_kernels_tests "${CMAKE_CURRENT_LIST_DIR}/tests/ax_mix_kernels_tests.cpp")
target_include_directories(mkw_ax_mix_kernels_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_ax_mix_kernels_tests PRIVATE cxx_std_17)
add_test(NAME mkw_ax_mix_kernels_tests COMMAND mkw_ax_mix_kernels_tests)
# The in-headset settings panel's controller chord, release latch, selection,
# scrolling and canvas mapping, plus the thread bridge they publish through.
add_executable(mkw_vr_settings_panel_tests tests/vr_settings_panel_tests.cpp src/vr/openxr_settings_panel.cpp)
File diff suppressed because it is too large. Load diff
+18 -3
View File
@@ -110,7 +110,7 @@ target_compile_definitions(mkw_runtime_common PRIVATE
_DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION)
target_link_libraries(mkw_runtime_common PRIVATE
aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx)
target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp)
target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls)
if(MKW_ENABLE_OPENXR)
target_link_libraries(mkw_runtime_common PRIVATE ${MKW_OPENXR_TARGET})
endif()
@@ -276,10 +276,10 @@ function(mkw_configure_product target)
mkw_bound_translated_compiles(${target})
if(MKW_PRODUCT_WITHOUT_GAME)
target_link_libraries(${target} PRIVATE
mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp)
mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls)
else()
target_link_libraries(${target} PRIVATE
mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp)
mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls)
endif()
target_link_libraries(${target} PRIVATE
@@ -379,6 +379,21 @@ function(mkw_configure_product target)
add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${MKW_INITIAL_PIPELINE_CACHE}"
"$<TARGET_FILE_DIR:${target}>/initial_pipeline_cache.db")
# Non-Windows TLS (runtime/src/hle/net/network_ssl.cpp's mbed TLS path) needs a trusted root
# CA bundle to verify server certificates against - Windows gets this for free from the OS via
# Schannel, mbed TLS does not ship one itself. Not SHA256-pinned like the DSP ROM above: unlike
# a fixed hardware ROM, this bundle is expected to be refreshed periodically as CAs rotate.
# Windows gets its trust store from Schannel, so only the platforms that actually build the
# mbed TLS path need the bundle beside the executable.
if(NOT MKW_PLATFORM_WINDOWS)
set(MKW_CA_CERTIFICATE_BUNDLE "${MKW_RUNTIME_SOURCE_DIR}/assets/certs/cacert.pem")
if(NOT EXISTS "${MKW_CA_CERTIFICATE_BUNDLE}")
message(FATAL_ERROR "Missing TLS root CA bundle: ${MKW_CA_CERTIFICATE_BUNDLE}")
endif()
add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${MKW_CA_CERTIFICATE_BUNDLE}" "$<TARGET_FILE_DIR:${target}>/cacert.pem")
endif()
endfunction()
# Android ships each product as the shared library SDLActivity loads; the base
+7 -2
View File
@@ -71,10 +71,15 @@ bool IsActive();
// accesses must use the checked Memory::* path.
// Windows user mode and x86-64 always use a 4 KiB base page, so those builds
// fold this to a compile-time false: it appears in every flat access and must
// not become a hot-path load. Only AArch64, where the page size is a kernel
// configuration (4/16/64 KiB), has to probe it at runtime.
// not become a hot-path load. On AArch64 the page size is a kernel
// configuration (4/16/64 KiB), so it is probed at runtime, except in the native
// Steam Frame build: that build only runs on the headset, whose SteamOS kernel
// uses 4 KiB pages, and Initialize() refuses to start on any other page size.
#if defined(_WIN32) || defined(__x86_64__)
#define MKW_GUEST_FLAT_FIXED_PAGE_SIZE 1
#elif defined(MKW_HEADSET_STEAM_FRAME) && !defined(__ANDROID__)
#define MKW_GUEST_FLAT_FIXED_PAGE_SIZE 1
#define MKW_GUEST_FLAT_VERIFY_PAGE_SIZE 1
#endif
#if defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE)
+58 -11
View File
@@ -8,6 +8,7 @@
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <limits>
#include <map>
#include <memory>
@@ -294,14 +295,57 @@ inline bool ReadExact(const std::filesystem::path& hostPath,
HostReadFailure& failure) {
failure = HostReadFailure::None;
std::ifstream file(hostPath, std::ios::binary);
if (!file.is_open()) {
failure = HostReadFailure::MissingFile;
return false;
// DVD callbacks run on the guest thread, often reading the same stream
// file many times per second. Reuse a bounded set of handles without
// sharing seek positions with another host thread.
struct OpenFile {
std::filesystem::path path;
std::ifstream stream;
std::streamoff size = 0;
std::filesystem::file_time_type modified{};
uint64_t lastUse = 0;
};
static thread_local std::vector<OpenFile> openFiles;
static thread_local uint64_t useCount = 0;
constexpr size_t kMaxOpenFiles = 8;
auto found = std::find_if(openFiles.begin(), openFiles.end(),
[&](const OpenFile& entry) { return entry.path == hostPath; });
if (found != openFiles.end()) {
std::error_code ec;
const auto modified = std::filesystem::last_write_time(hostPath, ec);
if (ec || modified != found->modified) {
openFiles.erase(found);
found = openFiles.end();
}
}
file.seekg(0, std::ios::end);
const std::streamoff fileSize = file.tellg();
if (found == openFiles.end()) {
std::ifstream file(hostPath, std::ios::binary);
if (!file.is_open()) {
failure = HostReadFailure::MissingFile;
return false;
}
file.seekg(0, std::ios::end);
const std::streamoff size = file.tellg();
if (size < 0) {
failure = HostReadFailure::BadOffset;
return false;
}
std::error_code ec;
const auto modified = std::filesystem::last_write_time(hostPath, ec);
if (openFiles.size() == kMaxOpenFiles) {
const auto oldest = std::min_element(openFiles.begin(), openFiles.end(),
[](const OpenFile& a, const OpenFile& b) { return a.lastUse < b.lastUse; });
openFiles.erase(oldest);
}
openFiles.push_back({hostPath, std::move(file), size,
ec ? std::filesystem::file_time_type{} : modified, 0});
found = std::prev(openFiles.end());
}
found->lastUse = ++useCount;
const std::streamoff fileSize = found->size;
if (fileSize < 0 ||
offset > static_cast<uint64_t>(std::numeric_limits<std::streamoff>::max()) ||
offset >= static_cast<uint64_t>(fileSize)) {
@@ -316,17 +360,20 @@ inline bool ReadExact(const std::filesystem::path& hostPath,
}
std::vector<uint8_t> staged(length);
file.seekg(static_cast<std::streamoff>(offset), std::ios::beg);
if (!file) {
found->stream.clear();
found->stream.seekg(static_cast<std::streamoff>(offset), std::ios::beg);
if (!found->stream) {
failure = HostReadFailure::BadOffset;
openFiles.erase(found);
return false;
}
if (length != 0) {
file.read(reinterpret_cast<char*>(staged.data()),
static_cast<std::streamsize>(length));
if (file.gcount() != static_cast<std::streamsize>(length)) {
found->stream.read(reinterpret_cast<char*>(staged.data()),
static_cast<std::streamsize>(length));
if (found->stream.gcount() != static_cast<std::streamsize>(length)) {
failure = HostReadFailure::ShortRead;
openFiles.erase(found);
return false;
}
}
+21 -3
View File
@@ -86,19 +86,37 @@ inline bool CopyBootstrapFile(const std::filesystem::path& sourceRoot,
std::error_code& ec) {
const auto source = sourceRoot / relativePath;
const auto destination = destinationRoot / relativePath;
auto copyOptions = std::filesystem::copy_options::none;
if (std::filesystem::exists(destination, ec)) {
return !ec;
if (ec) {
return false;
}
// Keep whatever the player has, except an empty file where the payload has content. WC24
// rejects a zero-length download or friend list outright and MKW reports that as a save
// error; since seeding only ran for missing files, such a file stayed broken for good.
const auto existingSize = std::filesystem::file_size(destination, ec);
if (ec) {
return false;
}
std::error_code sourceError;
const auto sourceSize = std::filesystem::file_size(source, sourceError);
if (existingSize != 0 || sourceError || sourceSize == 0) {
return true;
}
copyOptions = std::filesystem::copy_options::overwrite_existing;
} else if (ec) {
return false;
}
std::filesystem::create_directories(destination.parent_path(), ec);
if (ec) {
return false;
}
std::filesystem::copy_file(source, destination, std::filesystem::copy_options::none, ec);
std::filesystem::copy_file(source, destination, copyOptions, ec);
return !ec;
}
// Create these WC24 files only for a new profile; never overwrite user data.
// Create these WC24 files for a new profile, or refill one left empty; never overwrite user data.
constexpr std::string_view kBootstrapFiles[] = {
"shared2/wc24/misc.bin",
"shared2/wc24/nwc24dl.bin",
+38 -13
View File
@@ -1064,14 +1064,24 @@ inline const std::optional<std::string>& ControllerButton(size_t index) {
// user-specific paths. This is used by the in-game F10 settings bar.
inline bool WriteSetting(std::string_view section, std::string_view key, std::string_view value) {
const auto path = ResolveConfigPath();
std::ifstream input(path);
std::vector<std::string> lines;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
std::error_code existsError;
if (std::filesystem::exists(path, existsError)) {
// A file that's there but can't be read is not an empty one: rewriting it from nothing
// would throw away every other setting.
std::ifstream input(path);
std::string line;
while (input && std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
lines.push_back(std::move(line));
}
if (!input.eof()) {
std::cerr << "[runtime-config] Unable to read " << PathToUtf8(path) << "; not saving "
<< key << std::endl;
return false;
}
lines.push_back(std::move(line));
}
const std::string normalizedSection = Trim(section);
@@ -1124,19 +1134,34 @@ inline bool WriteSetting(std::string_view section, std::string_view key, std::st
}
}
// Written beside the file and renamed over it, so a crash or a full disk mid-write leaves the
// old file whole rather than a truncated one.
std::error_code ec;
if (path.has_parent_path()) {
std::filesystem::create_directories(path.parent_path(), ec);
}
std::ofstream output(path, std::ios::trunc);
if (!output) {
std::cerr << "[runtime-config] Unable to write " << PathToUtf8(path) << std::endl;
std::filesystem::path temporary = path;
temporary += ".tmp";
{
std::ofstream output(temporary, std::ios::trunc);
for (const auto& outputLine : lines) {
output << outputLine << '\n';
}
output.close();
if (!output) {
std::cerr << "[runtime-config] Unable to write " << PathToUtf8(temporary) << std::endl;
std::filesystem::remove(temporary, ec);
return false;
}
}
std::filesystem::rename(temporary, path, ec);
if (ec) {
std::cerr << "[runtime-config] Unable to replace " << PathToUtf8(path) << ": " << ec.message()
<< std::endl;
std::filesystem::remove(temporary, ec);
return false;
}
for (const auto& outputLine : lines) {
output << outputLine << '\n';
}
return static_cast<bool>(output);
return true;
}
inline std::string FormatString(std::string_view value) {
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <string>
// Settings > Updates: whether a newer release of the game exists, and, on a Frame that holds its own
// build, updating to it without leaving the headset.
//
// Both answers come from Launcher/steam-frame-install.sh, which already knows how to compare
// versions and how to build: `--check` reports, `--update` installs. The update reports its progress
// through a status file rather than Steam notifications, which the Frame's Steam does not show.
// The install writes update.conf beside the player's configuration when it put a build on this
// machine, so the Update button appears only where it can actually work; installed from a PC, the
// tab says to update there.
namespace update_check {
enum class State {
// This install cannot update itself: it was built on a PC, which is where updates are run.
PcInstall,
// It can, and nothing has been asked of it yet this session.
Idle,
Checking,
UpToDate,
Available,
// An update is running beside the game, as a service of its own: it carries on if the game is
// closed, and then opens it again when it is done.
Updating,
Failed,
};
struct Status {
State state = State::PcInstall;
// The release this build came from, or empty when it was built from a source tree.
std::string installedRelease;
// One line under the state: which release is available, what failed, or what step is running.
std::string detail;
// An update has installed a different build since this one started: it takes effect when the
// game is started again.
bool restartNeeded = false;
};
// What the Updates tab draws. Cheap enough to call every frame: a check or update in flight is
// answered from memory, and otherwise the files behind it are re-read at most once a second.
Status Current();
// Whether there is an Updates tab to show at all: this install can update itself, or it at least
// knows which release it is running. Neither is true of a build that came from somewhere else.
inline bool Relevant(const Status& status) {
return status.state != State::PcInstall || !status.installedRelease.empty();
}
// Asks the install script whether a newer release exists, in the background. Returns at once.
void StartCheck();
// Starts the update in the background: its own systemd user service runs the install script, which
// reports each step to the file Current() reads, so the game can stay open and show the progress.
// On false, `error` says why, for display.
bool StartUpdate(std::string& error);
} // namespace update_check
+2 -1
View File
@@ -5,6 +5,7 @@
#include <imgui.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_joystick.h>
#include <SDL3/SDL_platform.h>
#include <algorithm>
#include <array>
@@ -146,7 +147,7 @@ std::string BuildMappingString() {
mapping += std::string(kSteps[i].mappingKey) + ":" + *g_wizard.bindings[i] + ",";
}
}
mapping += "platform:Windows,";
mapping += std::string("platform:") + SDL_GetPlatform() + ",";
return mapping;
}
+11 -2
View File
@@ -11,6 +11,7 @@
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include "memory.h"
@@ -61,8 +62,9 @@ constexpr size_t kAllocationGranularity = 0x10000; // 64 KiB
constexpr size_t kHostPageSize = 0x1000;
// Only hosts that can expose a page larger than 4 KiB need to discover their
// size at runtime; see RequiresCheckedAccess() in guest_flat_memory.h.
#if !defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE)
// size at runtime, or check it when the build assumes 4 KiB; see
// RequiresCheckedAccess() in guest_flat_memory.h.
#if !defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) || defined(MKW_GUEST_FLAT_VERIFY_PAGE_SIZE)
size_t HostPageSize()
{
const long size = sysconf(_SC_PAGESIZE);
@@ -521,6 +523,13 @@ void Initialize(const std::vector<RegionRequest>& regions) {
#if !defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE)
g_requiresCheckedAccess = HostPageSize() > kGuestPageSize;
#elif defined(MKW_GUEST_FLAT_VERIFY_PAGE_SIZE)
if (HostPageSize() != kGuestPageSize) {
throw std::runtime_error(
"This is a Steam Frame build, which assumes the headset's 4 KiB memory pages, but this "
"kernel uses " + std::to_string(HostPageSize()) + "-byte pages. Build without "
"--headset steam_frame for this device.");
}
#endif
if (g_initialized) {
+150
View File
@@ -12,8 +12,16 @@
#if defined(__AVX2__)
#include <immintrin.h>
#define MKW_AX_MIX_AVX2 1
#define MKW_AX_MIX_NEON 0
#elif defined(__aarch64__)
// Advanced SIMD is architectural on AArch64: the NEON forms below are the arm64 counterparts of
// the AVX2 kernels, processing eight 16-bit samples per step as two int32x4 halves.
#include <arm_neon.h>
#define MKW_AX_MIX_AVX2 0
#define MKW_AX_MIX_NEON 1
#else
#define MKW_AX_MIX_AVX2 0
#define MKW_AX_MIX_NEON 0
#endif
namespace AxMixKernels {
@@ -99,10 +107,63 @@ inline uint16_t MixAddRampAvx2(int32_t* out, const int16_t* input, uint32_t coun
}
#endif
#if MKW_AX_MIX_NEON
inline uint16_t MixAddRampNeon(int32_t* out, const int16_t* input, uint32_t count,
uint16_t volume, uint16_t delta, int16_t& dpop) {
uint32_t i = 0;
int16_t last = dpop;
if (count >= 8) {
// Same ramp model as the AVX2 form: volume + k*delta stays inside int32 for every count
// the AX mix uses, and the 16-bit wrap is applied only where the scalar loop applies it.
const int32x4_t lanesLo = {0, 1, 2, 3};
const int32x4_t lanesHi = {4, 5, 6, 7};
const int32x4_t deltaVec = vdupq_n_s32(static_cast<int32_t>(delta));
const int32x4_t wrapMask = vdupq_n_s32(0xFFFF);
const int32x4_t blockStep = vdupq_n_s32(static_cast<int32_t>(delta) * 8);
const int32x4_t base = vdupq_n_s32(static_cast<int32_t>(volume));
int32x4_t rampLo = vmlaq_s32(base, lanesLo, deltaVec);
int32x4_t rampHi = vmlaq_s32(base, lanesHi, deltaVec);
int32x4_t lastBlock = vdupq_n_s32(0);
for (; i + 8 <= count; i += 8) {
const int16x8_t samples = vld1q_s16(input + i);
const int32x4_t sLo = vmovl_s16(vget_low_s16(samples));
const int32x4_t sHi = vmovl_s16(vget_high_s16(samples));
// int16 x uint16 fits in int32: the low 32-bit product is exact. Signed 32 -> 16
// saturation after the >> 15 is exactly clamp(-0x8000, 0x7fff).
int32x4_t scaledLo = vshrq_n_s32(vmulq_s32(sLo, vandq_s32(rampLo, wrapMask)), 15);
int32x4_t scaledHi = vshrq_n_s32(vmulq_s32(sHi, vandq_s32(rampHi, wrapMask)), 15);
scaledLo = vmovl_s16(vqmovn_s32(scaledLo));
scaledHi = vmovl_s16(vqmovn_s32(scaledHi));
vst1q_s32(out + i, vaddq_s32(vld1q_s32(out + i), scaledLo));
vst1q_s32(out + i + 4, vaddq_s32(vld1q_s32(out + i + 4), scaledHi));
lastBlock = scaledHi;
rampLo = vaddq_s32(rampLo, blockStep);
rampHi = vaddq_s32(rampHi, blockStep);
}
if (i != 0) {
last = static_cast<int16_t>(vgetq_lane_s32(lastBlock, 3));
volume = static_cast<uint16_t>(static_cast<uint32_t>(vgetq_lane_s32(rampLo, 0)));
}
}
for (; i < count; ++i) {
const int32_t scaled =
(static_cast<int32_t>(input[i]) * static_cast<int32_t>(volume)) >> 15;
const int16_t sample = ClampToS16(scaled);
out[i] += sample;
volume = static_cast<uint16_t>(volume + delta);
last = sample;
}
dpop = last;
return volume;
}
#endif
inline uint16_t MixAddRamp(int32_t* out, const int16_t* input, uint32_t count,
uint16_t volume, uint16_t delta, int16_t& dpop) {
#if MKW_AX_MIX_AVX2
return MixAddRampAvx2(out, input, count, volume, delta, dpop);
#elif MKW_AX_MIX_NEON
return MixAddRampNeon(out, input, count, volume, delta, dpop);
#else
return MixAddRampScalar(out, input, count, volume, delta, dpop);
#endif
@@ -162,10 +223,50 @@ inline uint16_t ScaleRampAvx2(int16_t* samples, uint32_t count, uint16_t volume,
}
#endif
#if MKW_AX_MIX_NEON
inline uint16_t ScaleRampNeon(int16_t* samples, uint32_t count, uint16_t volume,
uint16_t delta) {
uint32_t i = 0;
if (count >= 8) {
const int32x4_t lanesLo = {0, 1, 2, 3};
const int32x4_t lanesHi = {4, 5, 6, 7};
const int32x4_t deltaVec = vdupq_n_s32(static_cast<int32_t>(delta));
const int32x4_t wrapMask = vdupq_n_s32(0xFFFF);
const int32x4_t blockStep = vdupq_n_s32(static_cast<int32_t>(delta) * 8);
const int32x4_t base = vdupq_n_s32(static_cast<int32_t>(volume));
int32x4_t rampLo = vmlaq_s32(base, lanesLo, deltaVec);
int32x4_t rampHi = vmlaq_s32(base, lanesHi, deltaVec);
for (; i + 8 <= count; i += 8) {
const int16x8_t block = vld1q_s16(samples + i);
const int32x4_t scaledLo = vshrq_n_s32(
vmulq_s32(vmovl_s16(vget_low_s16(block)), vandq_s32(rampLo, wrapMask)), 15);
const int32x4_t scaledHi = vshrq_n_s32(
vmulq_s32(vmovl_s16(vget_high_s16(block)), vandq_s32(rampHi, wrapMask)), 15);
// Signed 32 -> 16 saturation is exactly clamp(-0x8000, 0x7fff).
vst1q_s16(samples + i, vcombine_s16(vqmovn_s32(scaledLo), vqmovn_s32(scaledHi)));
rampLo = vaddq_s32(rampLo, blockStep);
rampHi = vaddq_s32(rampHi, blockStep);
}
if (i != 0) {
volume = static_cast<uint16_t>(static_cast<uint32_t>(vgetq_lane_s32(rampLo, 0)));
}
}
for (; i < count; ++i) {
const int32_t scaled =
(static_cast<int32_t>(samples[i]) * static_cast<int32_t>(volume)) >> 15;
samples[i] = ClampToS16(scaled);
volume = static_cast<uint16_t>(volume + delta);
}
return volume;
}
#endif
inline uint16_t ScaleRamp(int16_t* samples, uint32_t count, uint16_t volume,
uint16_t delta) {
#if MKW_AX_MIX_AVX2
return ScaleRampAvx2(samples, count, volume, delta);
#elif MKW_AX_MIX_NEON
return ScaleRampNeon(samples, count, volume, delta);
#else
return ScaleRampScalar(samples, count, volume, delta);
#endif
@@ -214,10 +315,34 @@ inline void MixAccumRamp32Avx2(int32_t* dst, const int32_t* src, const uint16_t*
}
#endif
#if MKW_AX_MIX_NEON
inline void MixAccumRamp32Neon(int32_t* dst, const int32_t* src, const uint16_t* ramp,
uint32_t count) {
uint32_t i = 0;
for (; i + 4 <= count; i += 4) {
const int32x4_t source = vld1q_s32(src + i);
// The ramp is unsigned 16-bit, so it is a non-negative int32 and the widening signed
// multiply produces the exact 64-bit product (|product| < 2^47); the arithmetic >> 15
// then narrows to the same bits the scalar (int32)(p >> 15) keeps.
const int32x4_t gain = vreinterpretq_s32_u32(vmovl_u16(vld1_u16(ramp + i)));
const int64x2_t lo = vshrq_n_s64(vmull_s32(vget_low_s32(source), vget_low_s32(gain)), 15);
const int64x2_t hi = vshrq_n_s64(vmull_high_s32(source, gain), 15);
const int32x4_t result = vcombine_s32(vmovn_s64(lo), vmovn_s64(hi));
vst1q_s32(dst + i, vaddq_s32(vld1q_s32(dst + i), result));
}
for (; i < count; ++i) {
dst[i] += static_cast<int32_t>(
(static_cast<int64_t>(src[i]) * static_cast<int64_t>(ramp[i])) >> 15);
}
}
#endif
inline void MixAccumRamp32(int32_t* dst, const int32_t* src, const uint16_t* ramp,
uint32_t count) {
#if MKW_AX_MIX_AVX2
MixAccumRamp32Avx2(dst, src, ramp, count);
#elif MKW_AX_MIX_NEON
MixAccumRamp32Neon(dst, src, ramp, count);
#else
MixAccumRamp32Scalar(dst, src, ramp, count);
#endif
@@ -270,9 +395,32 @@ inline void LoadBigEndian32Avx2(int32_t* dst, const uint8_t* src, size_t count)
}
#endif
#if MKW_AX_MIX_NEON
// rev32 on byte lanes is the whole byte swap; four words per step.
inline void StoreBigEndian32Neon(uint8_t* dst, const int32_t* src, size_t count) {
size_t i = 0;
for (; i + 4 <= count; i += 4) {
const uint8x16_t value = vreinterpretq_u8_s32(vld1q_s32(src + i));
vst1q_u8(dst + i * sizeof(uint32_t), vrev32q_u8(value));
}
StoreBigEndian32Scalar(dst + i * sizeof(uint32_t), src + i, count - i);
}
inline void LoadBigEndian32Neon(int32_t* dst, const uint8_t* src, size_t count) {
size_t i = 0;
for (; i + 4 <= count; i += 4) {
const uint8x16_t value = vld1q_u8(src + i * sizeof(uint32_t));
vst1q_s32(dst + i, vreinterpretq_s32_u8(vrev32q_u8(value)));
}
LoadBigEndian32Scalar(dst + i, src + i * sizeof(uint32_t), count - i);
}
#endif
inline void StoreBigEndian32(uint8_t* dst, const int32_t* src, size_t count) {
#if MKW_AX_MIX_AVX2
StoreBigEndian32Avx2(dst, src, count);
#elif MKW_AX_MIX_NEON
StoreBigEndian32Neon(dst, src, count);
#else
StoreBigEndian32Scalar(dst, src, count);
#endif
@@ -281,6 +429,8 @@ inline void StoreBigEndian32(uint8_t* dst, const int32_t* src, size_t count) {
inline void LoadBigEndian32(int32_t* dst, const uint8_t* src, size_t count) {
#if MKW_AX_MIX_AVX2
LoadBigEndian32Avx2(dst, src, count);
#elif MKW_AX_MIX_NEON
LoadBigEndian32Neon(dst, src, count);
#else
LoadBigEndian32Scalar(dst, src, count);
#endif
+68 -13
View File
@@ -1,20 +1,19 @@
#include "hle_stubs.h"
#include <cstdint>
#include <limits>
#include "memory.h"
#include "recomp_mod_loader.h"
#include "runtime_log.h"
extern "C" void GxNotifyGuestRamDmaWrite(uint32_t addr, uint32_t size);
// Native because a crafted Yaz0 run writes past the caller's buffer
// (github.com/vabold/szsHaxx)
// https://github.com/vabold/Kinoko/blob/main/source/egg/core/Decomp.cc
extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
{
const uint32_t expandSize = (static_cast<uint32_t>(MemoryInline::FlatRead8(src + 4)) << 24) |
(static_cast<uint32_t>(MemoryInline::FlatRead8(src + 5)) << 16) |
(static_cast<uint32_t>(MemoryInline::FlatRead8(src + 6)) << 8) |
static_cast<uint32_t>(MemoryInline::FlatRead8(src + 7));
template <typename Access>
static uint32_t DecodeSZS(uint32_t src, uint32_t expandSize, Access& access) {
uint32_t srcIdx = 16;
uint32_t dstIdx = 0;
uint32_t mask = 0;
@@ -22,15 +21,15 @@ extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
while (static_cast<int32_t>(dstIdx) < static_cast<int32_t>(expandSize)) {
if (mask == 0) {
flags = MemoryInline::FlatRead8(src + srcIdx++);
flags = access.ReadSource(srcIdx++);
mask = 0x80;
}
if ((flags & mask) != 0) {
MemoryInline::FlatWrite8(dst + dstIdx++, MemoryInline::FlatRead8(src + srcIdx++));
access.WriteOutput(dstIdx++, access.ReadSource(srcIdx++));
} else {
const uint32_t high = MemoryInline::FlatRead8(src + srcIdx);
const uint32_t low = MemoryInline::FlatRead8(src + srcIdx + 1);
const uint32_t high = access.ReadSource(srcIdx);
const uint32_t low = access.ReadSource(srcIdx + 1);
srcIdx += 2;
const uint32_t rep = (high << 8) | low;
@@ -48,7 +47,7 @@ extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
uint32_t count = rep >> 12;
count = count != 0
? count + 2
: static_cast<uint32_t>(MemoryInline::FlatRead8(src + srcIdx++)) + 18;
: static_cast<uint32_t>(access.ReadSource(srcIdx++)) + 18;
for (uint32_t i = 0; i < count; ++i) {
if (dstIdx >= expandSize) {
@@ -59,7 +58,7 @@ extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
"The game stopped decoding a malformed Yaz0 file.");
std::abort();
}
MemoryInline::FlatWrite8(dst + dstIdx++, MemoryInline::FlatRead8(dst + copyIdx++));
access.WriteOutput(dstIdx++, access.ReadOutput(copyIdx++));
}
}
@@ -69,5 +68,61 @@ extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
return expandSize;
}
struct GuestSZSAccess {
uint32_t src;
uint32_t dst;
uint8_t ReadSource(uint32_t offset) const { return MemoryInline::FlatRead8(src + offset); }
uint8_t ReadOutput(uint32_t offset) const { return MemoryInline::FlatRead8(dst + offset); }
void WriteOutput(uint32_t offset, uint8_t value) const {
MemoryInline::FlatWrite8(dst + offset, value);
}
};
struct HostSZSAccess {
const uint8_t* src;
uint8_t* dst;
uint8_t ReadSource(uint32_t offset) const { return src[offset]; }
uint8_t ReadOutput(uint32_t offset) const { return dst[offset]; }
void WriteOutput(uint32_t offset, uint8_t value) const { dst[offset] = value; }
};
static bool HasDeferredReadPages(uint32_t address, size_t length) {
const uint32_t first = address >> MemoryInline::kPageShift;
const uint32_t last = static_cast<uint32_t>(
(static_cast<uint64_t>(address) + length - 1) >> MemoryInline::kPageShift);
for (uint32_t page = first; page <= last; ++page) {
if (MemoryInline::g_deferredReadCoveredPages[page] != 0) return true;
}
return false;
}
extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
{
const uint32_t expandSize = (static_cast<uint32_t>(MemoryInline::FlatRead8(src + 4)) << 24) |
(static_cast<uint32_t>(MemoryInline::FlatRead8(src + 5)) << 16) |
(static_cast<uint32_t>(MemoryInline::FlatRead8(src + 6)) << 8) |
static_cast<uint32_t>(MemoryInline::FlatRead8(src + 7));
// A token produces at least one byte; all-literal data is the largest
// valid input (one flag per eight output bytes). Three extra bytes cover
// the final malformed run before its output-overrun check aborts.
const size_t maxSourceBytes = 16ull + expandSize + (static_cast<uint64_t>(expandSize) + 7) / 8 + 3;
if (expandSize != 0 && expandSize <= static_cast<uint32_t>(std::numeric_limits<int32_t>::max()) &&
!GuestFlat::RequiresCheckedAccess() &&
Memory::Contains(src, maxSourceBytes) && Memory::Contains(dst, expandSize) &&
!HasDeferredReadPages(src, maxSourceBytes) && !HasDeferredReadPages(dst, expandSize) &&
!RecompMod::ExecutableWriteGuardMayHit(dst, expandSize)) {
HostSZSAccess access{Memory::GetPointer(src, maxSourceBytes), Memory::GetPointer(dst, expandSize)};
const uint32_t decoded = DecodeSZS(src, expandSize, access);
GxNotifyGuestRamDmaWrite(dst, decoded);
return decoded;
}
GuestSZSAccess access{src, dst};
return DecodeSZS(src, expandSize, access);
}
PPC_NATIVE_OVERRIDE(80218C2C, EGG_Decomp_decodeSZS_80218c2c, uint32_t,
(uint32_t src, uint32_t dst), (src, dst));
+6 -5
View File
@@ -648,9 +648,10 @@ extern "C" int32_t Network_HLE_OpenDevice(const char* path, uint32_t mode) {
if (!path) {
return -101;
}
if (!RuntimeConfigFile::NetworkEnabled(true)) {
// The guest opens several /dev/net nodes at boot and retries; report the
// reason online will not work exactly once.
const bool isIpTop = std::strcmp(path, "/dev/net/ip/top") == 0;
const bool isSsl = std::strcmp(path, "/dev/net/ssl") == 0;
// KD and NCD provide local identity/configuration services even offline.
if ((isIpTop || isSsl) && !RuntimeConfigFile::NetworkEnabled(true)) {
static bool reported = false;
if (!reported) {
reported = true;
@@ -665,10 +666,10 @@ extern "C" int32_t Network_HLE_OpenDevice(const char* path, uint32_t mode) {
kind = DeviceKind::KdTime;
} else if (std::strcmp(path, "/dev/net/ncd/manage") == 0) {
kind = DeviceKind::NcdManage;
} else if (std::strcmp(path, "/dev/net/ip/top") == 0) {
} else if (isIpTop) {
kind = DeviceKind::IpTop;
EnsureSocketRuntime();
} else if (std::strcmp(path, "/dev/net/ssl") == 0) {
} else if (isSsl) {
kind = DeviceKind::Ssl;
EnsureSocketRuntime();
} else {
+1
View File
@@ -242,6 +242,7 @@ void WritePollResults(uint32_t outAddress,
const std::vector<NetworkPollContract::CopiedDescriptor>& descriptors);
// network_socket.cpp
int32_t DeleteWiiSocket(uint32_t fd);
void CleanupAllWiiSockets();
sockaddr_in ReadWiiSockAddr(uint32_t addr);
int32_t HandleIpTopIoctl(uint32_t cmd, uint32_t inBuf, uint32_t inLen, uint32_t outBuf,
+3 -2
View File
@@ -21,7 +21,7 @@ static int32_t NewWiiSocket(uint32_t af, uint32_t type, uint32_t protocol) {
return wiiFd;
}
static int32_t DeleteWiiSocket(uint32_t fd) {
int32_t DeleteWiiSocket(uint32_t fd) {
WiiSocket* s = GetWiiSocket(fd);
if (!s) {
return -SO_EBADF;
@@ -467,7 +467,8 @@ int32_t HandleIpTopIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const s
const int ret = sendto(s->native, reinterpret_cast<const char*>(sendData), static_cast<int>(sendSize),
static_cast<int>(flags), destPtr, destLen);
const int hostError = ret < 0 ? NativeLastError() : 0;
int32_t result = SocketResult(ret);
// Diagnostics may change the native error; use the send result captured above.
int32_t result = ret >= 0 ? SocketResult(ret) : SocketErrorResult(hostError);
if (patchedWrite && ret == static_cast<int>(sendSize)) {
result = static_cast<int32_t>(in[0].size);
}
+314 -5
View File
@@ -1,4 +1,20 @@
#include "network_internal.h"
#include "runtime_config.h"
#include "runtime_log.h"
#ifndef _WIN32
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/entropy.h>
#include <mbedtls/error.h>
#include <mbedtls/net_sockets.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <optional>
#endif
namespace NetworkHle {
@@ -38,6 +54,9 @@ constexpr int kMaxSslSessions = 4;
struct SslSession {
bool active = false;
bool handshaked = false;
// A failed POSIX TLS write cannot be resumed with a new guest buffer.
// Keep the slot and socket ownership intact until explicit teardown.
bool failed = false;
bool plaintextWfc = false;
uint32_t socketFd = UINT32_MAX;
NativeSocket native = kInvalidSocket;
@@ -56,6 +75,11 @@ struct SslSession {
CredHandle cred{};
CtxtHandle context{};
SecPkgContext_StreamSizes sizes{};
#else
bool haveSsl = false;
mbedtls_ssl_context sslContext{};
mbedtls_ssl_config sslConfig{};
mbedtls_net_context netContext{};
#endif
};
@@ -539,26 +563,298 @@ static int32_t SslRead(SslSession& ssl, uint8_t* out, uint32_t size) {
return copied == 0 ? SSL_ERR_ZERO : static_cast<int32_t>(copied);
}
#else
// Windows gets TLS for free from the OS (Schannel, above) - mbed TLS is this project's own
// vendored equivalent for everywhere else (runtime/third_party/mbedtls, see runtime/CMakeLists.txt
// for why mbed TLS specifically). The CA chain and RNG are expensive to set up (parsing ~150 root
// certificates, seeding entropy) and read-only once built, so they're shared process-wide instead
// of being redone per SSL session.
static bool g_mbedtlsCaLoaded = false;
static mbedtls_x509_crt g_mbedtlsCaChain;
static mbedtls_entropy_context g_mbedtlsEntropy;
static mbedtls_ctr_drbg_context g_mbedtlsCtrDrbg;
static ssize_t SendSslSocket(NativeSocket socket, const uint8_t* data, size_t size) {
#ifdef __APPLE__
const int noSigPipe = 1;
if (setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)) != 0) {
return -1;
}
return send(socket, data, size, 0);
#else
return send(socket, data, size, MSG_NOSIGNAL);
#endif
}
static int MbedtlsSend(void* context, const unsigned char* data, size_t size) {
const auto* net = static_cast<mbedtls_net_context*>(context);
const ssize_t result = SendSslSocket(net->fd, data, size);
if (result >= 0) {
return static_cast<int>(result);
}
if (errno == EINTR) {
return MBEDTLS_ERR_SSL_WANT_WRITE;
}
if (errno == EPIPE || errno == ECONNRESET) {
return MBEDTLS_ERR_NET_CONN_RESET;
}
return MBEDTLS_ERR_NET_SEND_FAILED;
}
static int MbedtlsRecv(void* context, unsigned char* data, size_t size) {
const int result = mbedtls_net_recv(context, data, size);
// Blocking socket timeouts must leave the TLS session retryable.
if (result == MBEDTLS_ERR_NET_RECV_FAILED && (errno == EAGAIN || errno == EWOULDBLOCK)) {
return MBEDTLS_ERR_SSL_WANT_READ;
}
return result;
}
// Mirrors ax_mix.cpp's FindDspCoefficientRom exactly - same three places a bundled asset can live
// depending on platform and how the binary was launched (next to the desktop executable, the
// Android app's own data directory, or a source-tree checkout during development).
static std::optional<std::filesystem::path> FindCaCertificateBundle() {
if (const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory()) {
const auto adjacent = *executableDirectory / "cacert.pem";
if (std::filesystem::is_regular_file(adjacent)) {
return adjacent;
}
}
#if defined(__ANDROID__)
const auto androidAsset = RuntimeConfigFile::ApplicationDataDirectory() / "cacert.pem";
if (std::filesystem::is_regular_file(androidAsset)) {
return androidAsset;
}
#endif
for (auto base = std::filesystem::current_path(); !base.empty();) {
const auto sourceTreeAsset = base / "runtime" / "assets" / "certs" / "cacert.pem";
if (std::filesystem::is_regular_file(sourceTreeAsset)) {
return sourceTreeAsset;
}
const auto parent = base.parent_path();
if (parent == base) {
break;
}
base = parent;
}
return std::nullopt;
}
// Lazy, once-per-process: the first real SSL use pays for parsing the CA bundle and seeding the
// RNG, every session after that reuses the result. Returns false (logging once) if the bundle is
// missing or unparseable - callers treat that as a normal handshake failure, not a crash, since a
// missing TLS root store shouldn't take down gameplay that never touches the network.
static bool EnsureMbedtlsGlobalsInitialized() {
static const bool initialized = [] {
mbedtls_x509_crt_init(&g_mbedtlsCaChain);
mbedtls_entropy_init(&g_mbedtlsEntropy);
mbedtls_ctr_drbg_init(&g_mbedtlsCtrDrbg);
const char* personalization = "wiicompiled_ssl";
if (mbedtls_ctr_drbg_seed(&g_mbedtlsCtrDrbg, mbedtls_entropy_func, &g_mbedtlsEntropy,
reinterpret_cast<const unsigned char*>(personalization),
std::strlen(personalization)) != 0) {
NetFail("ssl: failed to seed TLS random number generator");
return false;
}
const auto bundle = FindCaCertificateBundle();
if (!bundle) {
NetFail("ssl: missing TLS root CA bundle (cacert.pem) - HTTPS connections will fail");
return false;
}
const int parseRet = mbedtls_x509_crt_parse_file(&g_mbedtlsCaChain, bundle->string().c_str());
if (parseRet < 0) {
char errorBuffer[256];
mbedtls_strerror(parseRet, errorBuffer, sizeof(errorBuffer));
NetFail("ssl: failed to parse CA bundle %s: %s", bundle->string().c_str(), errorBuffer);
return false;
}
return true;
}();
g_mbedtlsCaLoaded = initialized;
return initialized;
}
// Builds this session's mbed TLS handshake state exactly once - a second call (e.g. the handshake
// re-running after DOHANDSHAKE was already satisfied) is a no-op via ssl.haveSsl.
static int32_t EnsureMbedtlsSession(SslSession& ssl) {
if (ssl.haveSsl) {
return SSL_OK;
}
if (!EnsureMbedtlsGlobalsInitialized()) {
return SSL_ERR_FAILED;
}
mbedtls_ssl_init(&ssl.sslContext);
mbedtls_ssl_config_init(&ssl.sslConfig);
if (mbedtls_ssl_config_defaults(&ssl.sslConfig, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM,
MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
return SSL_ERR_FAILED;
}
// Real certificate validation, matching Schannel's SCH_CRED_AUTO_CRED_VALIDATION on the
// Windows side above - a self-signed or wrong-hostname certificate must fail the handshake,
// not just get logged.
mbedtls_ssl_conf_authmode(&ssl.sslConfig, MBEDTLS_SSL_VERIFY_REQUIRED);
mbedtls_ssl_conf_ca_chain(&ssl.sslConfig, &g_mbedtlsCaChain, nullptr);
mbedtls_ssl_conf_rng(&ssl.sslConfig, mbedtls_ctr_drbg_random, &g_mbedtlsCtrDrbg);
if (mbedtls_ssl_setup(&ssl.sslContext, &ssl.sslConfig) != 0) {
return SSL_ERR_FAILED;
}
// The hostname drives both SNI (which certificate the server presents) and the CN/SAN check
// mbedtls_ssl_conf_authmode enforces above - required, not optional, same reasoning as the
// Windows path's own "refuse an empty hostname" check just above SslHandshakeImpl.
mbedtls_ssl_set_hostname(&ssl.sslContext, ssl.hostname.c_str());
ssl.netContext.fd = static_cast<int>(ssl.native);
mbedtls_ssl_set_bio(&ssl.sslContext, &ssl.netContext, MbedtlsSend, MbedtlsRecv, nullptr);
ssl.haveSsl = true;
return SSL_OK;
}
static void ClearSslSession(SslSession& ssl) {
if (ssl.haveSsl) {
mbedtls_ssl_free(&ssl.sslContext);
mbedtls_ssl_config_free(&ssl.sslConfig);
}
ssl = {};
}
static int32_t SslHandshakeImpl(SslSession&) {
static int32_t SslHandshakeImpl(SslSession& ssl) {
if (ssl.plaintextWfc) {
ssl.handshaked = true;
return SSL_OK;
}
if (ssl.handshaked) {
return SSL_OK;
}
if (ssl.native == kInvalidSocket) {
return SSL_ERR_SYSCALL;
}
// mbed TLS can authenticate a certificate chain without authenticating a server identity when
// no hostname is set - refuse that ambiguous mode, matching the Windows path's own check.
if (ssl.hostname.empty()) {
return SSL_ERR_VCOMMONNAME;
}
const int32_t setupRet = EnsureMbedtlsSession(ssl);
if (setupRet != SSL_OK) {
return setupRet;
}
// Receive timeouts are retryable, but the handshake must still terminate.
const auto handshakeDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(15);
int handshakeRet;
while ((handshakeRet = mbedtls_ssl_handshake(&ssl.sslContext)) != 0) {
if (handshakeRet == MBEDTLS_ERR_SSL_WANT_READ || handshakeRet == MBEDTLS_ERR_SSL_WANT_WRITE) {
if (std::chrono::steady_clock::now() >= handshakeDeadline) {
NetFail("ssl handshake TIMED OUT host=%s", ssl.hostname.c_str());
return SSL_ERR_FAILED;
}
continue;
}
char errorBuffer[256];
mbedtls_strerror(handshakeRet, errorBuffer, sizeof(errorBuffer));
NetFail("ssl handshake FAILED host=%s mbedtls_err=%s", ssl.hostname.c_str(), errorBuffer);
return handshakeRet == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED ? SSL_ERR_VCOMMONNAME : SSL_ERR_FAILED;
}
ssl.handshaked = true;
return SSL_OK;
}
static int32_t FailSslWrite(SslSession& ssl) {
ssl.failed = true;
// Stop transport I/O without deleting the guest descriptor or freeing a
// session still referenced by the IOCTLV_NET_SSL_WRITE caller.
::shutdown(ssl.native, SHUT_RDWR);
return SSL_ERR_FAILED;
}
static int32_t SslWrite(SslSession&, const uint8_t*, uint32_t) {
return SSL_ERR_FAILED;
static int32_t SslWrite(SslSession& ssl, const uint8_t* data, uint32_t size) {
if (!data || size == 0) {
return SSL_ERR_ZERO;
}
const int32_t handshakeRet = SslHandshake(ssl);
if (handshakeRet != SSL_OK) {
return handshakeRet;
}
if (ssl.plaintextWfc) {
uint32_t total = 0;
while (total < size) {
const ssize_t sent = SendSslSocket(ssl.native, data + total, size - total);
if (sent <= 0) {
return SSL_ERR_SYSCALL;
}
total += static_cast<uint32_t>(sent);
}
return static_cast<int32_t>(total);
}
// mbed TLS is allowed to write fewer bytes than requested in one call (e.g. when size exceeds
// one TLS record) - the caller must resend the remainder starting from where it left off, so
// loop here until every byte is actually written rather than returning the first partial count.
uint32_t totalWritten = 0;
const auto writeDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(15);
while (totalWritten < size) {
const int ret = mbedtls_ssl_write(&ssl.sslContext, data + totalWritten, size - totalWritten);
if (ret > 0) {
totalWritten += static_cast<uint32_t>(ret);
continue;
}
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
if (std::chrono::steady_clock::now() >= writeDeadline) {
return FailSslWrite(ssl);
}
continue;
}
return FailSslWrite(ssl);
}
return static_cast<int32_t>(totalWritten);
}
static int32_t SslRead(SslSession&, uint8_t*, uint32_t) {
return SSL_ERR_FAILED;
static int32_t SslRead(SslSession& ssl, uint8_t* out, uint32_t size) {
if (!out || size == 0) {
return SSL_ERR_ZERO;
}
const int32_t handshakeRet = SslHandshake(ssl);
if (handshakeRet != SSL_OK) {
return handshakeRet;
}
if (ssl.plaintextWfc) {
const ssize_t ret = recv(ssl.native, out, size, 0);
if (ret == 0) {
return SSL_ERR_ZERO;
}
if (ret < 0) {
return SSL_ERR_RAGAIN;
}
return static_cast<int32_t>(ret);
}
const int ret = mbedtls_ssl_read(&ssl.sslContext, out, size);
if (ret == 0 || ret == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) {
return SSL_ERR_ZERO;
}
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
return SSL_ERR_RAGAIN;
}
if (ret < 0) {
return SSL_ERR_FAILED;
}
return ret;
}
#endif
// The handshake runs on every SSL read/write, so a failure repeats for as long
// as the session lives; report only the first one.
static int32_t SslHandshake(SslSession& ssl) {
if (ssl.failed) {
return SSL_ERR_FAILED;
}
const int32_t result = SslHandshakeImpl(ssl);
if (result != SSL_OK && !ssl.loggedHandshakeFail) {
ssl.loggedHandshakeFail = true;
@@ -640,6 +936,14 @@ int32_t HandleSslIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const std
const int timeoutMs = 15000;
setsockopt(socket->native, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeoutMs), sizeof(timeoutMs));
setsockopt(socket->native, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&timeoutMs), sizeof(timeoutMs));
#else
// Match the Windows 15s timeout so a peer that accepts the TCP connection but stalls
// during the TLS handshake or a later read/write can't hang this thread forever. POSIX
// takes a struct timeval here, not a plain millisecond count like Windows does.
struct timeval timeout {};
timeout.tv_sec = 15;
setsockopt(socket->native, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(socket->native, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#endif
WriteSslReturn(in, SSL_OK);
return 0;
@@ -656,6 +960,11 @@ int32_t HandleSslIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const std
WriteSslReturn(in, SSL_ERR_ID);
return 0;
}
// Reject before NAS buffering can acknowledge data on a failed session.
if (g_sslSessions[sslId].failed) {
WriteSslReturn(in, SSL_ERR_FAILED);
return 0;
}
if (out.size() < 2 || !out[1].address) {
WriteSslReturn(in, SSL_ERR_FAILED);
return 0;
+12 -10
View File
@@ -222,7 +222,6 @@ bool ProcessAlarmQueue(CpuContext* cpu, int maxToProcess)
throw;
}
DecrementSchedulerDisableCount();
RunDeferredReschedule(cpu);
}
}
} catch (const ::Memory::AccessViolation& e) {
@@ -233,7 +232,7 @@ bool ProcessAlarmQueue(CpuContext* cpu, int maxToProcess)
// Host DNS workers never touch guest memory. Commit their output here on
// the scheduler thread, waking synchronous IOS waiters or queuing async IOS
// callbacks before the callback drain below.
bool completionNeedsReschedule = false;
bool completionNeedsReschedule = handledAny;
if (Network_HLE_ProcessCompletions(cpu)) {
handledAny = true;
completionNeedsReschedule = true;
@@ -446,15 +445,18 @@ PPC_NATIVE_OVERRIDE_VOID(801A08E0, OS__SetPeriodicAlarm_801a08e0, (CpuContext* c
// returning 0 when the manager pointer (0x80386298) is null.
extern "C" uint32_t RFLiIsWorking_HLE_800bd860()
{
// Pump alarms/callbacks on the current guest thread when available. Using a
// detached persistent context here can leave the busy loop waiting on work
// that completed on the wrong scheduling context.
CpuContext* cpu = TryGetCpuContext();
if (!cpu) {
cpu = &GetPersistentCpuContext();
}
// Alarm callbacks interrupt the caller; keep their register writes private.
GuestInterruptCallbackContext interrupt;
CpuContext* cpu = interrupt.get();
EnsureSda1Base(cpu);
ProcessAlarmQueue(cpu, 32);
IncrementSchedulerDisableCount();
try {
ProcessAlarmQueue(cpu, 32);
} catch (...) {
DecrementSchedulerDisableCount();
throw;
}
DecrementSchedulerDisableCount();
// Now return the actual "working" status
constexpr uint32_t kRflManagerPtrAddr = 0x80386298u;
+8 -3
View File
@@ -104,10 +104,15 @@ extern "C" void DVDInit_8015EA1C();
extern "C" uint32_t g_dvdFstReservedBase;
extern "C" uint32_t g_dvdFstReservedSize;
// Byte-wise copy into guest RAM plus the DMA notification the GX caches need.
// DVD DMA normally targets ordinary RAM. Keep scalar writes for executable
// ranges so the translated-code write guard still sees every modified byte.
static void CopyToGuestAsDma(uint32_t dest, const uint8_t* data, size_t size) {
for (size_t i = 0; i < size; ++i) {
Memory::Write8(dest + static_cast<uint32_t>(i), data[i]);
if (size != 0 && !RecompMod::ExecutableWriteGuardMayHit(dest, size)) {
std::memcpy(Memory::GetPointer(dest, size), data, size);
} else {
for (size_t i = 0; i < size; ++i) {
Memory::Write8(dest + static_cast<uint32_t>(i), data[i]);
}
}
GxNotifyGuestRamDmaWrite(dest, static_cast<uint32_t>(size));
}
+145
View File
@@ -4,6 +4,14 @@
#include "nand_internal.h"
#include <atomic>
#include <cerrno>
#ifdef __linux__
#include <linux/fs.h>
#include <sys/syscall.h>
#endif
// ============================================================================
// Local helpers
// ============================================================================
@@ -44,6 +52,32 @@ static FileHandle* ResolveNandFileHandle(const char* who, uint32_t fileInfoPtr)
// The synchronous RVL NAND* library
// ============================================================================
static bool RenameNoReplace(const std::filesystem::path& from,
const std::filesystem::path& to,
std::error_code& error) {
#ifdef _WIN32
if (MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_WRITE_THROUGH)) {
error.clear();
return true;
}
error = std::error_code(static_cast<int>(GetLastError()), std::system_category());
return false;
#elif defined(__linux__)
const int result = syscall(SYS_renameat2, AT_FDCWD, from.c_str(), AT_FDCWD, to.c_str(), RENAME_NOREPLACE);
if (result == 0) {
error.clear();
return true;
}
error = std::error_code(errno, std::generic_category());
return false;
#else
(void)from;
(void)to;
error = std::make_error_code(std::errc::operation_not_supported);
return false;
#endif
}
extern "C" int32_t NANDInit_HLE(void) {
// Initialize ISFS
ISFS_OpenLib_Initialize(&GetPersistentCpuContext());
@@ -348,6 +382,11 @@ extern "C" int32_t NANDCreateDir_HLE(uint32_t pathPtr, uint32_t perm, uint32_t a
PPC_NATIVE_OVERRIDE(8019BBE0, NANDCreateDir_HLE, int32_t, (uint32_t pathPtr, uint32_t perm, uint32_t attr), (pathPtr, perm, attr));
extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
// A cross-mount move is implemented as several host operations. Keep two
// guest moves from interleaving those operations and corrupting recovery.
static std::mutex moveMutex;
std::lock_guard<std::mutex> lock(moveMutex);
const char* srcPath = srcPathPtr ? (const char*)Memory::GetPointer(srcPathPtr) : nullptr;
const char* dstPath = dstPathPtr ? (const char*)Memory::GetPointer(dstPathPtr) : nullptr;
@@ -383,6 +422,112 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
return NAND_RESULT_OK;
}
// Flatpak can expose the managed NAND and an external Riivolution save
// directory as separate mounts. Linux cannot rename across mounts, but
// nandMove must still work for files such as banner.bin. Preserve the
// operation's semantics with a copy followed by source removal.
if (ec == std::errc::cross_device_link) {
static std::atomic<uint64_t> moveSequence{0};
#ifdef _WIN32
const auto processId = GetCurrentProcessId();
#else
const auto processId = getpid();
#endif
std::filesystem::path scratchHost;
std::error_code scratchEc;
for (unsigned attempt = 0; attempt < 128; ++attempt) {
const auto name = ".nandmove-" + std::to_string(processId) + "-" +
std::to_string(moveSequence.fetch_add(1)) + "-" +
std::to_string(attempt);
const auto candidate = dstDirectoryHost / name;
scratchEc.clear();
if (std::filesystem::create_directory(candidate, scratchEc)) {
scratchHost = candidate;
break;
}
if (scratchEc && scratchEc != std::errc::file_exists) {
LogNandError("NANDMove", "failed to claim temporary directory '%s': %s",
HostPathText(candidate).c_str(), scratchEc.message().c_str());
return NAND_RESULT_UNKNOWN;
}
}
if (scratchHost.empty()) {
LogNandError("NANDMove", "could not claim a unique temporary directory");
return NAND_RESULT_UNKNOWN;
}
const bool sourceIsDirectory = IsDirectory(srcHost);
const std::filesystem::path tempHost = scratchHost / srcName;
const auto cleanupScratch = [&]() {
std::error_code cleanupEc;
std::filesystem::remove_all(scratchHost, cleanupEc);
if (cleanupEc) {
LogNandError("NANDMove", "failed to clean up temporary directory '%s': %s",
HostPathText(scratchHost).c_str(), cleanupEc.message().c_str());
}
};
std::error_code copyEc;
if (sourceIsDirectory) {
std::filesystem::copy(srcHost, tempHost,
std::filesystem::copy_options::recursive, copyEc);
} else {
std::filesystem::copy_file(srcHost, tempHost, copyEc);
}
if (copyEc) {
LogNandError("NANDMove", "cross-mount copy failed: %s", copyEc.message().c_str());
cleanupScratch();
return NAND_RESULT_UNKNOWN;
}
std::error_code publishEc;
if (sourceIsDirectory) {
RenameNoReplace(tempHost, dstHost, publishEc);
} else {
// link(2) and CreateHardLink do not replace an existing destination,
// unlike rename(2) on POSIX. Both paths are already on the target
// filesystem, so the link is a no-replace publication operation.
std::filesystem::create_hard_link(tempHost, dstHost, publishEc);
}
if (publishEc) {
LogNandError("NANDMove", "failed to publish cross-mount copy: %s",
publishEc.message().c_str());
cleanupScratch();
return NAND_RESULT_UNKNOWN;
}
cleanupScratch();
std::error_code removeEc;
std::filesystem::remove_all(srcHost, removeEc);
if (!removeEc) {
LogNandWarning("NANDMove", "used copy/remove fallback across mounts");
return NAND_RESULT_OK;
}
// Keep the source as the authoritative copy when cleanup fails. The
// destination was published atomically on its own mount; regular files
// are rolled back below, while directories keep the complete copy when
// their source removal was only partial. Cross-mount moves cannot
// provide crash-atomicity, so this is best effort.
LogNandError("NANDMove", "copy succeeded but source removal failed: %s",
removeEc.message().c_str());
if (sourceIsDirectory) {
// remove_all may have removed only part of a directory tree. Keep
// the complete published copy rather than rolling it back to a
// partially deleted source.
LogNandWarning("NANDMove", "preserving published directory copy after partial source removal");
} else {
std::error_code rollbackEc;
std::filesystem::remove_all(dstHost, rollbackEc);
if (rollbackEc) {
LogNandError("NANDMove", "failed to roll back destination '%s': %s",
HostPathText(dstHost).c_str(), rollbackEc.message().c_str());
}
}
return NAND_RESULT_UNKNOWN;
}
LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str());
return NAND_RESULT_UNKNOWN;
}
+4
View File
@@ -1551,6 +1551,10 @@ int RuntimeMain(int argc, char** argv) {
WiiRemoteInput::ConfigureSdlHints(RuntimeConfigFile::WiiRemotesEnabled(true));
const AuroraInfo auroraInfo = aurora_initialize(0, nullptr, &auroraConfig);
if (auroraInfo.initializationStatus != AURORA_INITIALIZATION_SUCCESS) {
throw std::runtime_error(auroraInfo.initializationError != nullptr
? auroraInfo.initializationError : "No supported graphics backend is available");
}
if (requestedBackend != BACKEND_AUTO && auroraInfo.backend != requestedBackend) {
RT_LOG(RT_TAG_RUNTIME) << "graphics_api=\"" << backend
<< "\" is not available on this system; aurora fell back to \""
+1 -1
View File
@@ -48,7 +48,7 @@ std::optional<std::filesystem::path> ExecutableDirectory() noexcept {
return (ec ? std::filesystem::path(path) : resolved).parent_path();
#elif defined(__ANDROID__)
// Bundled read-only runtime resources (wii_bootstrap/, dsp_coef.bin,
// initial_pipeline_cache.db) unpacked from the APK by the activity. Stands
// initial_pipeline_cache.db, cacert.pem) unpacked from the APK by the activity. Stands
// in for the executable directory the desktop builds look next to.
if (const char* resources = std::getenv("MKW_ANDROID_RESOURCES_DIR"); resources && *resources) {
return std::filesystem::path(resources);
+111
View File
@@ -11,6 +11,7 @@
#include "music_attenuation.h"
#include "runtime_config.h"
#include "runtime_log.h"
#include "update_check.h"
#include "vr/camera_toggle.h"
#include "vr/mkw_vr_culling.h"
#include "vr/mkw_vr_first_person.h"
@@ -2094,6 +2095,107 @@ void DrawDiagnosticsSettings() {
}
}
bool g_updatePromptOpen = false;
std::string g_updateError;
void DrawUpdatePrompt() {
constexpr const char* kTitle = "Update";
if (g_updatePromptOpen && !ImGui::IsPopupOpen(kTitle)) ImGui::OpenPopup(kTitle);
if (!ImGui::BeginPopupModal(kTitle, &g_updatePromptOpen, ImGuiWindowFlags_AlwaysAutoResize)) return;
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextUnformatted("The update is built here on the Frame, in the background, which takes a "
"while: keep the Frame on its charger. You can keep playing meanwhile, "
"though the game may stutter while it compiles. Close the game whenever "
"you like: the update carries on and opens it again when it is done.");
ImGui::PopTextWrapPos();
if (ImGui::Button("Start the update", ImVec2(Scaled(180.0f), 0.0f))) {
update_check::StartUpdate(g_updateError);
g_updatePromptOpen = false;
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(Scaled(120.0f), 0.0f))) g_updatePromptOpen = false;
if (!g_updatePromptOpen) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawUpdateSettings() {
const update_check::Status status = update_check::Current();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
if (status.installedRelease.empty()) {
ImGui::TextUnformatted("This build came from a source tree rather than a release.");
} else {
ImGui::Text("Release %s", status.installedRelease.c_str());
}
const bool busy = status.state == update_check::State::Checking ||
status.state == update_check::State::Updating;
switch (status.state) {
case update_check::State::PcInstall:
ImGui::TextDisabled("The PC that built this installs its updates. Run the installer there "
"again with --update.");
break;
case update_check::State::Idle:
break;
case update_check::State::Checking:
ImGui::TextDisabled("Looking for a newer release...");
break;
case update_check::State::UpToDate:
ImGui::TextDisabled("%s", status.detail.c_str());
break;
case update_check::State::Available:
ImGui::TextUnformatted(status.detail.c_str());
break;
case update_check::State::Updating:
ImGui::Text("Updating: %s", status.detail.c_str());
ImGui::TextDisabled("Keep playing, or close the game: the update carries on and opens it "
"again when it is done.");
break;
case update_check::State::Failed:
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", status.detail.c_str());
break;
}
if (!g_updateError.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", g_updateError.c_str());
}
if (status.restartNeeded && status.state != update_check::State::Updating) {
// The update replaced the build on disk; this process is still the old one.
ImGui::TextUnformatted("The update is installed. Quit and start the game again to use it.");
}
ImGui::PopTextWrapPos();
if (status.state == update_check::State::PcInstall) {
return;
}
if (status.restartNeeded && status.state != update_check::State::Updating) {
if (ImGui::Button("Quit")) g_exitPromptOpen = true;
ImGui::SameLine();
}
ImGui::BeginDisabled(busy);
if (ImGui::Button(status.state == update_check::State::Idle ? "Check for updates" : "Check again")) {
g_updateError.clear();
update_check::StartCheck();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Asks github.com for the newest release, and Retro Rewind's server for\n"
"its newest pack when this install has one. Nothing is downloaded\n"
"until you choose to update.");
}
if (status.state == update_check::State::Available) {
ImGui::SameLine();
if (ImGui::Button("Update now")) {
g_updateError.clear();
g_updatePromptOpen = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Builds the update here on the Frame, in the background. The game\n"
"can stay open and shows the progress here.");
}
}
ImGui::EndDisabled();
// The prompt itself is drawn at the top level, as the exit prompt is: the tab lives both in the
// headset's panel and in a desktop menu, and a modal must outlive whichever opened it.
}
void DrawFpsOverlay() {
AuroraPresentTiming presentTiming{};
aurora_get_present_timing(&presentTiming);
@@ -2291,6 +2393,11 @@ void DrawTopBar() {
ImGui::EndMenu();
}
if (update_check::Relevant(update_check::Current()) && ImGui::BeginMenu("Updates")) {
DrawUpdateSettings();
ImGui::EndMenu();
}
const ImGuiStyle& style = ImGui::GetStyle();
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + style.FramePadding.x * 2.0f;
const float exitWidth = ImGui::CalcTextSize("X").x + style.FramePadding.x * 2.0f;
@@ -2479,6 +2586,9 @@ void DrawVrSettingsPanelWindow() {
tab("Controllers", DrawControllerSettings);
tab("Audio", DrawAudioSettings);
tab("Diagnostics", DrawDiagnosticsSettings);
if (update_check::Relevant(update_check::Current())) {
tab("Updates", DrawUpdateSettings);
}
ImGui::EndTabBar();
}
}
@@ -2687,6 +2797,7 @@ void Draw() noexcept {
DrawFpsOverlay();
DrawTopBar();
DrawExitPrompt();
DrawUpdatePrompt();
controller_mapping_wizard::Draw();
// The wizard captures raw presses; keep them out of the game.
const bool inputBlocked = controller_mapping_wizard::IsActive() || g_rebind.active;
+368
View File
@@ -0,0 +1,368 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "update_check.h"
#include "runtime_config.h"
#include <array>
#include <charconv>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <mutex>
#include <string>
#include <string_view>
#include <system_error>
#include <thread>
// Updating itself is the Frame's build, installed by Launcher/steam-frame-install.sh: a desktop
// Linux game that holds its own build directory. Everywhere else the tab only reports the release
// it is running, which needs none of the machinery below.
#if defined(__linux__) && !defined(__ANDROID__)
#define MKW_UPDATE_CHECK_SUPPORTED 1
#include <sys/wait.h>
#else
#define MKW_UPDATE_CHECK_SUPPORTED 0
#endif
namespace update_check {
namespace {
// Written by Launcher/steam-frame-install.sh when it built on this machine, naming itself and the
// service that runs it. Its absence is what tells the tab this install is updated from a PC.
constexpr std::string_view kConfigFileName = "update.conf";
// An update's progress, and a check's answer. Separate files, so a check never overwrites the
// result of the update that opened the game.
constexpr std::string_view kStatusFileName = "update-status";
constexpr std::string_view kCheckStatusFileName = "update-status-check";
// What the Update button leaves for the update to pick up.
constexpr std::string_view kRequestFileName = "update-request";
// Stamped beside the executable by the install; absent for a build from a source tree.
constexpr std::string_view kReleaseTagFileName = ".release-tag";
// A finished update's status belongs on screen when the game opens right afterwards, which is what
// the update does. Older than this it is history, and the tab starts clean.
constexpr auto kStatusLifetime = std::chrono::hours(1);
// A running update rewrites its status at every step and every percent of its build, so one that
// has said nothing for this long was stopped without a chance to say so (the Frame lost power).
constexpr auto kRunningStatusLifetime = std::chrono::hours(2);
// The tab is drawn every frame; the files behind it are not read anywhere near that often.
constexpr auto kReadInterval = std::chrono::seconds(1);
std::string Trimmed(std::string_view text) {
const auto first = text.find_first_not_of(" \t\r\n");
if (first == std::string_view::npos) {
return {};
}
return std::string(text.substr(first, text.find_last_not_of(" \t\r\n") - first + 1));
}
std::string ReadFirstLine(const std::filesystem::path& file) {
std::ifstream stream(file);
std::string line;
if (!stream || !std::getline(stream, line)) {
return {};
}
return Trimmed(line);
}
struct Config {
std::filesystem::path script;
// The install's work directory, which holds the build the update reuses. The script defaults to
// the same place, but an install given --work-dir does not live there.
std::filesystem::path workDir;
std::string service;
bool Usable() const { return !script.empty() && !service.empty(); }
};
Config ReadConfig() {
Config config;
#if !MKW_UPDATE_CHECK_SUPPORTED
return config;
#else
std::ifstream stream(RuntimeConfigFile::ApplicationDataDirectory() / kConfigFileName);
if (!stream) {
return config;
}
std::string line;
while (std::getline(stream, line)) {
const auto separator = line.find('=');
if (line.empty() || line.front() == '#' || separator == std::string::npos) {
continue;
}
const std::string key = Trimmed(std::string_view(line).substr(0, separator));
const std::string value = Trimmed(std::string_view(line).substr(separator + 1));
if (key == "script") {
config.script = value;
} else if (key == "work_dir") {
config.workDir = value;
} else if (key == "service") {
config.service = value;
}
}
std::error_code ec;
if (!config.script.empty() && !std::filesystem::is_regular_file(config.script, ec)) {
// The work directory was removed, so there is nothing here to update from any more.
config.script.clear();
}
return config;
#endif
}
std::string InstalledRelease() {
if (const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory()) {
return ReadFirstLine(*executableDirectory / kReleaseTagFileName);
}
return {};
}
// What this session has been told, which outranks the files until the game is next started: a check
// or update it asked for, and what came back.
std::mutex g_mutex;
bool g_sessionStateSet = false;
State g_sessionState = State::Idle;
std::string g_sessionDetail;
// What was last read from disk, and when, so drawing the tab does not re-read it every frame.
std::chrono::steady_clock::time_point g_lastRead;
bool g_haveRead = false;
bool g_haveFileState = false;
State g_fileState = State::Idle;
std::string g_fileDetail;
bool g_canUpdate = false;
// The release this process was started from, read once: an update replaces the stamp on disk while
// the old build is still the one running. The stamp as it is now tells whether that has happened.
std::string g_runningRelease;
bool g_haveRunningRelease = false;
std::string g_releaseOnDisk;
void SetSessionState(State state, std::string detail) {
const std::lock_guard<std::mutex> lock(g_mutex);
g_sessionStateSet = true;
g_sessionState = state;
g_sessionDetail = std::move(detail);
}
std::string ShellQuoted(const std::string& text) {
std::string quoted = "'";
for (const char character : text) {
if (character == '\'') {
quoted += "'\\''";
} else {
quoted += character;
}
}
quoted += '\'';
return quoted;
}
// Runs `command`, returning its exit status and keeping the last line of its output for a message.
int Run(const std::string& command, std::string& lastLine) {
lastLine.clear();
#if !MKW_UPDATE_CHECK_SUPPORTED
(void)command;
return -1;
#else
FILE* pipe = popen(command.c_str(), "r");
if (pipe == nullptr) {
return -1;
}
std::array<char, 512> buffer{};
while (std::fgets(buffer.data(), static_cast<int>(buffer.size()), pipe) != nullptr) {
if (const std::string line = Trimmed(buffer.data()); !line.empty()) {
lastLine = line;
}
}
const int status = pclose(pipe);
if (status == -1 || !WIFEXITED(status)) {
return -1;
}
return WEXITSTATUS(status);
#endif
}
// Reads a "<state>\t<unix time>\t<text>" line the script wrote. False when there is none, it is too
// old to be about now, or it cannot be understood.
bool ReadStatusFile(const std::filesystem::path& file, State& state, std::string& detail) {
const std::string line = ReadFirstLine(file);
if (line.empty()) {
return false;
}
const auto firstTab = line.find('\t');
const auto secondTab = line.find('\t', firstTab + 1);
if (firstTab == std::string::npos || secondTab == std::string::npos) {
return false;
}
const std::string written = line.substr(0, firstTab);
long long seconds = 0;
const char* const secondsBegin = line.data() + firstTab + 1;
if (std::from_chars(secondsBegin, line.data() + secondTab, seconds).ec != std::errc{}) {
return false;
}
const auto age = std::chrono::system_clock::now() -
std::chrono::system_clock::time_point(std::chrono::seconds(seconds));
const auto lifetime = written == "running" ? kRunningStatusLifetime : kStatusLifetime;
if (age > lifetime || age < -lifetime) {
return false;
}
if (written == "running") {
state = State::Updating;
} else if (written == "available") {
state = State::Available;
} else if (written == "uptodate" || written == "done") {
state = State::UpToDate;
} else if (written == "failed") {
state = State::Failed;
} else {
return false;
}
detail = line.substr(secondTab + 1);
return true;
}
} // namespace
Status Current() {
const std::lock_guard<std::mutex> lock(g_mutex);
// Drawn every frame, so the files behind it are read on a timer instead.
const auto now = std::chrono::steady_clock::now();
if (!g_haveRead || now - g_lastRead > kReadInterval) {
g_releaseOnDisk = InstalledRelease();
if (!g_haveRunningRelease) {
g_runningRelease = g_releaseOnDisk;
g_haveRunningRelease = true;
}
g_lastRead = now;
g_haveRead = true;
g_canUpdate = ReadConfig().Usable();
g_fileState = State::Idle;
g_fileDetail.clear();
g_haveFileState = ReadStatusFile(RuntimeConfigFile::ApplicationDataDirectory() / kStatusFileName,
g_fileState, g_fileDetail);
}
Status status;
status.installedRelease = g_runningRelease;
status.restartNeeded = g_releaseOnDisk != g_runningRelease;
if (!g_canUpdate) {
status.state = State::PcInstall;
return status;
}
// An update's own reports outrank what this session last said: one started here (the session
// says Updating until the update writes its first step), or one this game was reopened after.
const bool followUpdate = !g_sessionStateSet || g_sessionState == State::Updating;
if (followUpdate && g_haveFileState) {
status.state = g_fileState;
status.detail = g_fileDetail;
} else if (g_sessionStateSet) {
status.state = g_sessionState;
status.detail = g_sessionDetail;
} else {
status.state = State::Idle;
}
return status;
}
void StartCheck() {
{
const std::lock_guard<std::mutex> lock(g_mutex);
if (g_sessionStateSet && (g_sessionState == State::Checking || g_sessionState == State::Updating)) {
return;
}
}
const Config config = ReadConfig();
if (!config.Usable()) {
return;
}
SetSessionState(State::Checking, "Asking github.com for the newest release");
// Detached: it talks to the network and must not hold up a frame, and there is no shutdown
// ordering to manage because it touches nothing the game owns.
std::thread([config] {
const auto statusFile = RuntimeConfigFile::ApplicationDataDirectory() / kCheckStatusFileName;
std::error_code ec;
std::filesystem::remove(statusFile, ec);
std::string command = "WIICOMPILED_UPDATE_STATUS=" + ShellQuoted(statusFile.string()) + ' ' +
ShellQuoted(config.script.string()) + " --check";
if (!config.workDir.empty()) {
command += " --work-dir " + ShellQuoted(config.workDir.string());
}
command += " 2>&1";
std::string lastLine;
const int exitStatus = Run(command, lastLine);
State state = State::Failed;
std::string detail;
if (ReadStatusFile(statusFile, state, detail)) {
SetSessionState(state, detail);
} else if (exitStatus == 0) {
// It ran and said nothing this can read, which is still an answer: ask again later.
SetSessionState(State::Idle, "The check said nothing this version understands");
} else {
std::cerr << "[update] the check failed (exit " << exitStatus << "): " << lastLine << std::endl;
SetSessionState(State::Failed,
lastLine.empty() ? "The check could not be run" : lastLine);
}
std::filesystem::remove(statusFile, ec);
}).detach();
}
bool StartUpdate(std::string& error) {
error.clear();
const Config config = ReadConfig();
if (!config.Usable()) {
error = "This build was installed from a PC, so the update is run there.";
return false;
}
const auto dataDirectory = RuntimeConfigFile::ApplicationDataDirectory();
std::error_code ec;
std::filesystem::create_directories(dataDirectory, ec);
// How the update opens the game again when it is done. Steam names the running title; without
// one (a build started from a terminal) the update still runs and the game is started by hand.
std::string steamGameId;
for (const char* variable : {"SteamGameId", "SteamAppId"}) {
if (const char* value = std::getenv(variable); value != nullptr && *value != '\0') {
steamGameId = value;
break;
}
}
{
std::ofstream request(dataDirectory / kRequestFileName, std::ios::trunc);
if (!request) {
error = "Could not write to " + dataDirectory.string();
return false;
}
request << "# Written by the game's Update button; the update removes it.\n";
if (!steamGameId.empty()) {
request << "steam_game_id=" << steamGameId << '\n';
}
}
// The previous update's result must not be read as this one's before it has written anything.
std::filesystem::remove(dataDirectory / kStatusFileName, ec);
// The update must outlive the game if the player closes it meanwhile, and Steam ends the game's
// own processes when it closes, so it runs as a service of its own. --no-block: started, not
// waited for.
std::string lastLine;
const std::string command =
"systemctl --user start --no-block " + ShellQuoted(config.service) + " 2>&1";
if (const int exitStatus = Run(command, lastLine); exitStatus != 0) {
std::filesystem::remove(dataDirectory / kRequestFileName, ec);
std::cerr << "[update] could not start " << config.service << " (exit " << exitStatus
<< "): " << lastLine << std::endl;
error = lastLine.empty() ? "Could not start the update service" : lastLine;
return false;
}
SetSessionState(State::Updating, "Starting the update");
// Read the files again at once, rather than show the removed status for up to a second.
const std::lock_guard<std::mutex> lock(g_mutex);
g_haveRead = false;
return true;
}
} // namespace update_check
#undef MKW_UPDATE_CHECK_SUPPORTED
+123
View File
@@ -0,0 +1,123 @@
// The AX mix kernels' vector forms (AVX2 on x86-64, NEON on arm64) must be bit-exact with the
// scalar reference loops they replace. Random blocks at every tail length, plus the ramp and
// clamp extremes; on a build with neither vector form this compares the scalar loops with
// themselves.
#include "../src/hle/audio/ax_mix_kernels.h"
#include <cstdint>
#include <cstring>
#include <iostream>
#include <random>
#include <vector>
namespace {
int g_failures = 0;
void Fail(const char* kernel, uint32_t count, uint32_t volume, uint32_t delta) {
if (++g_failures <= 10) {
std::cerr << kernel << " differs from the scalar loop: count " << count << ", volume "
<< volume << ", delta " << delta << '\n';
}
}
template <typename T>
std::vector<T> RandomBlock(std::mt19937& rng, uint32_t count, int64_t low, int64_t high) {
std::uniform_int_distribution<int64_t> dist(low, high);
std::vector<T> block(count);
for (auto& value : block) {
value = static_cast<T>(dist(rng));
}
return block;
}
void CheckRamps(std::mt19937& rng, uint32_t count, uint16_t volume, uint16_t delta) {
const auto input = RandomBlock<int16_t>(rng, count, INT16_MIN, INT16_MAX);
const auto bus = RandomBlock<int32_t>(rng, count, -(1 << 24), 1 << 24);
auto expectedOut = bus;
auto actualOut = bus;
int16_t expectedDpop = 1234;
int16_t actualDpop = 1234;
const uint16_t expectedVolume =
AxMixKernels::MixAddRampScalar(expectedOut.data(), input.data(), count, volume, delta, expectedDpop);
const uint16_t actualVolume =
AxMixKernels::MixAddRamp(actualOut.data(), input.data(), count, volume, delta, actualDpop);
if (expectedOut != actualOut || expectedDpop != actualDpop || expectedVolume != actualVolume) {
Fail("MixAddRamp", count, volume, delta);
}
auto expectedSamples = input;
auto actualSamples = input;
const uint16_t expectedScaled = AxMixKernels::ScaleRampScalar(expectedSamples.data(), count, volume, delta);
const uint16_t actualScaled = AxMixKernels::ScaleRamp(actualSamples.data(), count, volume, delta);
if (expectedSamples != actualSamples || expectedScaled != actualScaled) {
Fail("ScaleRamp", count, volume, delta);
}
}
void CheckAccumAndMarshal(std::mt19937& rng, uint32_t count) {
const auto src = RandomBlock<int32_t>(rng, count, INT32_MIN, INT32_MAX);
const auto ramp = RandomBlock<uint16_t>(rng, count, 0, UINT16_MAX);
const auto bus = RandomBlock<int32_t>(rng, count, -(1 << 24), 1 << 24);
auto expected = bus;
auto actual = bus;
AxMixKernels::MixAccumRamp32Scalar(expected.data(), src.data(), ramp.data(), count);
AxMixKernels::MixAccumRamp32(actual.data(), src.data(), ramp.data(), count);
if (expected != actual) {
Fail("MixAccumRamp32", count, 0, 0);
}
std::vector<uint8_t> expectedBytes(count * 4);
std::vector<uint8_t> actualBytes(count * 4);
AxMixKernels::StoreBigEndian32Scalar(expectedBytes.data(), src.data(), count);
AxMixKernels::StoreBigEndian32(actualBytes.data(), src.data(), count);
if (expectedBytes != actualBytes) {
Fail("StoreBigEndian32", count, 0, 0);
}
std::vector<int32_t> expectedWords(count);
std::vector<int32_t> actualWords(count);
AxMixKernels::LoadBigEndian32Scalar(expectedWords.data(), expectedBytes.data(), count);
AxMixKernels::LoadBigEndian32(actualWords.data(), expectedBytes.data(), count);
if (expectedWords != actualWords || expectedWords != src) {
Fail("LoadBigEndian32", count, 0, 0);
}
}
} // namespace
int main() {
std::mt19937 rng(0x41584D58u);
std::uniform_int_distribution<uint32_t> any16(0, UINT16_MAX);
const uint16_t edges[] = {0, 1, 0x7FFF, 0x8000, 0x8001, 0xFFFE, 0xFFFF};
// Every tail length around the vector widths, and the AX frame sizes (96 per 3 ms frame,
// 160 at the 5 ms subframe the AXWii list can use).
for (uint32_t count = 0; count <= 40; ++count) {
for (uint16_t volume : edges) {
for (uint16_t delta : edges) {
CheckRamps(rng, count, volume, delta);
}
}
for (int trial = 0; trial < 64; ++trial) {
CheckRamps(rng, count, static_cast<uint16_t>(any16(rng)), static_cast<uint16_t>(any16(rng)));
}
CheckAccumAndMarshal(rng, count);
}
for (uint32_t count : {96u, 160u, 255u}) {
for (int trial = 0; trial < 256; ++trial) {
CheckRamps(rng, count, static_cast<uint16_t>(any16(rng)), static_cast<uint16_t>(any16(rng)));
CheckAccumAndMarshal(rng, count);
}
}
if (g_failures != 0) {
std::cerr << g_failures << " mismatches\n";
return 1;
}
std::cout << "ax mix kernels match the scalar loops (avx2 " << MKW_AX_MIX_AVX2 << ", neon "
<< MKW_AX_MIX_NEON << ")\n";
return 0;
}
@@ -27,7 +27,8 @@ public sealed partial class CxxLinearCodeGenerator
IReadOnlyDictionary<uint, GuestAbiContract> stateFreeAbiContracts,
IReadOnlyDictionary<uint, string> stateFreeCallSymbols,
IReadOnlyDictionary<GuestStateFreeCallSiteKey, GuestStateFreeCallVariant> stateFreeCallSiteVariants,
IReadOnlySet<uint> modOverridableCallTargets)
IReadOnlySet<uint> modOverridableCallTargets,
bool shareLrContinuationDispatch)
{
if (ins is IrPhi)
{
@@ -410,11 +411,18 @@ public sealed partial class CxxLinearCodeGenerator
fallbackPad = IndentPad(indent + 1);
}
EmitLocalLrContinuationDispatch(sb, fallbackPad, labelNames);
sb.AppendLine($"{fallbackPad}if (TranslatedFunctionRegistry::FindByAddressPtr(ctx->lr) != nullptr) {{");
sb.AppendLine($"{fallbackPad} InvokeIndirectCpu(ctx->lr, ctx);");
sb.AppendLine($"{fallbackPad}}}");
sb.AppendLine($"{fallbackPad}return;");
if (shareLrContinuationDispatch)
{
sb.AppendLine($"{fallbackPad}goto lr_continuation_dispatch;");
}
else
{
EmitLocalLrContinuationDispatch(sb, fallbackPad, labelNames);
sb.AppendLine($"{fallbackPad}if (TranslatedFunctionRegistry::FindByAddressPtr(ctx->lr) != nullptr) {{");
sb.AppendLine($"{fallbackPad} InvokeIndirectCpu(ctx->lr, ctx);");
sb.AppendLine($"{fallbackPad}}}");
sb.AppendLine($"{fallbackPad}return;");
}
if (localFallthroughLr.HasValue)
{
sb.AppendLine($"{pad}}}");
@@ -241,12 +241,14 @@ public sealed partial class CxxLinearCodeGenerator
var labelNames = func.Blocks.ToDictionary(b => b.Label, b => SanitizeLabel(b.Label), StringComparer.OrdinalIgnoreCase);
var instructionContinuationLabels = new Dictionary<uint, string>();
var needsInstructionContinuationLabels = func.Blocks
var continuationCallCount = func.Blocks
.SelectMany(static block => block.Instructions)
.OfType<IrCall>()
.Any(call =>
.Count(call =>
TryParseAddress(call.Target, out var target) &&
(nonReturningCallTargets.Contains(target) || lrContinuationCallTargets.Contains(target)));
var needsInstructionContinuationLabels = continuationCallCount > 0;
var shareLrContinuationDispatch = continuationCallCount > 1;
if (needsInstructionContinuationLabels)
{
foreach (var trace in func.Blocks.SelectMany(static block => block.Instructions).OfType<IrTracePpc>())
@@ -405,7 +407,7 @@ public sealed partial class CxxLinearCodeGenerator
_activeGpuFifoBurstSlot = gpuFifoBurstPlan.Slot(block.Label, i);
try
{
EmitInstruction(block.Label, directCallOrdinal, block.Instructions[i], body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase, localFallthroughLr, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets);
EmitInstruction(block.Label, directCallOrdinal, block.Instructions[i], body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase, localFallthroughLr, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets, shareLrContinuationDispatch);
}
finally
{
@@ -423,7 +425,7 @@ public sealed partial class CxxLinearCodeGenerator
switch (term)
{
case IrUndefined undef:
EmitInstruction(block.Label, -1, undef, body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase: false, localFallthroughLr: null, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets);
EmitInstruction(block.Label, -1, undef, body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase: false, localFallthroughLr: null, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets, shareLrContinuationDispatch);
AppendFlush(body, " ");
body.AppendLine(" return;");
break;
@@ -509,6 +511,18 @@ public sealed partial class CxxLinearCodeGenerator
body.AppendLine();
}
if (shareLrContinuationDispatch)
{
// Every call site has already reloaded the callee's state.
// Keep the complete local target set, but emit it only once.
body.AppendLine(" return;");
body.AppendLine("[[maybe_unused]] lr_continuation_dispatch:");
EmitLocalLrContinuationDispatch(body, " ", labelNames);
body.AppendLine(" if (TranslatedFunctionRegistry::FindByAddressPtr(ctx->lr) != nullptr) {");
body.AppendLine(" InvokeIndirectCpu(ctx->lr, ctx);");
body.AppendLine(" }");
body.AppendLine(" return;");
}
body.Append('}');
// The residency discovery pass only exists for its side effects
// on the recorder; materializing its text costs a full copy of
@@ -1942,6 +1942,22 @@ public sealed partial class PpcLifter
new IrCall(string.Empty, TargetLabel(ins, validAddresses, preferFallthrough: false), blArgs)
};
case "bltl":
{
var crField = "cr0";
if (ops.Count > 0 && ops[0] is PpcConditionRegisterOperand crOp)
{
crField = NormalizeRegister(crOp.Name);
}
return new IrInstruction[]
{
new IrAssign("lr", IrValue.Imm((int)ins.EndAddress)),
new IrBranch("blt", TargetLabel(ins, validAddresses, preferFallthrough: false),
$"0x{ins.EndAddress:X8}", crField)
};
}
case "bcl":
{
var rawInstr = ReadRawInstruction(ins);
@@ -0,0 +1,70 @@
using System.Security.Cryptography;
using WiiCompiled.Setup.Linux;
using Xunit;
namespace Translator.Tests;
public sealed class LinuxDiscExtractionTests
{
private sealed class Reporter : IInstallReporter
{
public void Progress(string stage, string message, int percent) { }
public void Diagnostic(string line) { }
}
[Theory]
[InlineData("valid")]
[InlineData("wrong-hash")]
[InlineData("failed")]
[InlineData("cancelled")]
[InlineData("recovery")]
public async Task ExtractionPreservesLiveDataUntilValidationSucceeds(string mode)
{
if (!OperatingSystem.IsLinux()) return;
var root = Path.Combine(Path.GetTempPath(), "linux-disc-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var assets = Path.Combine(root, "Assets");
var data = Path.Combine(assets, "DATA");
Directory.CreateDirectory(data);
File.WriteAllText(Path.Combine(data, "old"), "working install");
if (mode == "recovery") Directory.Move(data, data + ".replaced");
var tool = Path.Combine(root, "nodtool");
File.WriteAllText(tool, """
#!/bin/bash
if [[ "$1" == info ]]; then echo 'Game ID: RMCP01'; exit 0; fi
mkdir -p "$3/sys" "$3/files/rel"
printf dol > "$3/sys/main.dol"
printf rel > "$3/files/rel/StaticR.rel"
""" + "\n" + (mode is "failed" or "recovery" ? "exit 1\n" : mode == "cancelled" ? "sleep 30\n" : "exit 0\n"));
File.SetUnixFileMode(tool, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
var manifest = new ProjectManifest
{
GameId = "RMCP01", Region = "P",
DolSha256 = mode == "wrong-hash" ? new string('0', 64) : Hash("dol"),
RelSha256 = Hash("rel")
};
using var cancellation = new CancellationTokenSource();
if (mode == "cancelled") cancellation.CancelAfter(TimeSpan.FromMilliseconds(200));
var task = DiscTool.ValidateAndExtractAsync("disc.iso", manifest, assets, root, tool, new Reporter(), cancellation.Token);
if (mode == "valid")
{
await task;
Assert.False(File.Exists(Path.Combine(data, "old")));
Assert.Equal("dol", File.ReadAllText(Path.Combine(data, "sys", "main.dol")));
Assert.Equal("rel", File.ReadAllText(Path.Combine(assets, "StaticR.rel")));
}
else
{
await Assert.ThrowsAnyAsync<Exception>(() => task);
Assert.Equal("working install", File.ReadAllText(Path.Combine(data, "old")));
}
Assert.False(Directory.Exists(data + ".extracting"));
Assert.False(Directory.Exists(data + ".replaced"));
}
finally { Directory.Delete(root, recursive: true); }
}
private static string Hash(string text) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
}
@@ -14,6 +14,23 @@ public class PpcLifterAdditionalCoverageTests
private static PpcInstruction Instruction(uint raw, string mnemonic, params PpcOperand[] operands)
=> PpcInstruction.Synthetic(0x80000000, raw, mnemonic, operands);
[Fact]
public void LinkedConditionalBranchSetsLrBeforeEitherPath()
{
var branch = PpcDecoder.Decode(0x80004394, 0x41800029);
var ir = Assert.Single(new PpcLifter().Lift(new[] { branch })).Ir;
Assert.Equal("bltl", branch.Mnemonic);
var lr = Assert.IsType<IrAssign>(ir[0]);
Assert.Equal("lr", lr.Destination);
Assert.Equal(unchecked((int)0x80004398u), lr.Value.Constant);
var decision = Assert.IsType<IrBranch>(ir[1]);
Assert.Equal("blt", decision.Condition);
Assert.Equal("0x800043BC", decision.TrueLabel);
Assert.Equal("0x80004398", decision.FalseLabel);
}
[Fact]
public void LiftsAdditionalNonDotArithmeticAndLogicalForms()
{
@@ -35,6 +35,23 @@ public class RetroWfcPayloadLoweringTests
Assert.Equal("moduleFunction", pointer.TargetKind);
}
[Fact]
public void AlteredProductionPayloadFailsSignatureValidation()
{
var root = Path.Combine(Path.GetTempPath(), "payload-validation-" + Guid.NewGuid().ToString("N"));
try
{
var payload = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory,
"TestAssets", "RetroWfcPayload", "binary", "payload.RMCPD00.bin"));
payload[^1] ^= 1;
Directory.CreateDirectory(Path.Combine(root, "binary"));
File.WriteAllBytes(Path.Combine(root, "binary", "payload.RMCPD00.bin"), payload);
Assert.Throws<InvalidDataException>(() =>
WiiCompiled.Setup.Common.RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(root));
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void ProductionPayloadValidatesAndTranslatesEverySupportedPatch()
{
@@ -0,0 +1,52 @@
using Translator.Core.Analysis.Ssa;
using Translator.Core.Analysis.Representation;
using Translator.Core.CodeGen;
using Translator.Core.Ir;
using Translator.Core.Representation;
using Xunit;
namespace Translator.Tests;
public class SharedLrContinuationCodeGenTests
{
[Theory]
[InlineData(2)]
[InlineData(20)]
public void MultipleHooksShareOneCompleteDispatchAndKeepNormalFallthrough(int callCount)
{
var instructions = new List<IrInstruction>();
for (var i = 0; i < callCount; i++)
{
instructions.Add(new IrAssign("lr", IrValue.Imm(unchecked((int)(0x80001004u + (uint)i * 4u)))));
instructions.Add(new IrCall(string.Empty, "0x81800000", Array.Empty<IrValue>()));
}
instructions.Add(new IrAssign("r3", IrValue.Imm(8)));
instructions.Add(new IrReturn(null));
var function = new IrFunction("shared_lr_continuation", "0x80001000", new[]
{
new IrBasicBlock("0x80001000", instructions),
new IrBasicBlock("0x80001100", new IrInstruction[]
{
new IrAssign("r3", IrValue.Imm(1)), new IrReturn(null)
})
});
var types = new RepresentationEnvironment(new Dictionary<string, ValueRepresentation>
{
["lr"] = ValueRepresentation.UInt32, ["r3"] = ValueRepresentation.UInt32
});
var code = new CxxLinearCodeGenerator().Emit(0x80001000,
new SsaTransformer().Convert(function),
new FunctionAbiClassification("shared_lr_continuation", ValueRepresentation.Void), types,
lrContinuationCallTargets: new HashSet<uint> { 0x81800000u });
Assert.Equal(1, code.Split("switch (ctx->lr)").Length - 1);
Assert.Equal(callCount, code.Split("goto lr_continuation_dispatch;").Length - 1);
Assert.Equal(callCount, code.Split("if (ctx->lr != ").Length - 1);
Assert.Contains("case 0x80001100u:", code);
Assert.Contains("goto loc_80001100;", code);
Assert.Contains("InvokeIndirectCpu(ctx->lr, ctx);", code);
var sharedLabel = code.IndexOf("lr_continuation_dispatch:", StringComparison.Ordinal);
Assert.True(code.IndexOf("r3 = 8;", StringComparison.Ordinal) < sharedLabel);
Assert.True(code.IndexOf("case 0x80001100u:", StringComparison.Ordinal) > sharedLabel);
}
}
@@ -27,6 +27,7 @@
<ProjectReference Include="..\..\src\Translator.Core\Translator.Core.csproj" />
<ProjectReference Include="..\..\src\Translator.Cli\Translator.Cli.csproj" />
<ProjectReference Include="..\..\..\Launcher\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
<ProjectReference Include="../../../Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj" />
</ItemGroup>
<ItemGroup>