10 Commits
Author SHA1 Message Date
PodeCaradoxandClaude Opus 5.5 fa5766398b Steam Frame docs: build, then bring it to the Frame; no game screenshots
The README and both guides start with building the archive from source
(WSL2 on Windows included) and then copying it to the Frame by USB stick or
SSH, since there are no ready-made builds.  The two screenshots of the game
are gone from the README and the repository; the galaxy cover stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:27:58 +02:00
PodeCaradoxandClaude Opus 5.5 93abf02e90 Steam Frame docs: no ready-made builds, the archive is built from source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:16:19 +02:00
PodeCaradoxandClaude Opus 5.5 c2073d16e1 README: Steam Frame section with a notice, the install and the build from source
The notice says what the project is and is not (unofficial, no game data,
own disc, non-commercial) and how rights holders get something removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:14:11 +02:00
PodeCaradoxandClaude Opus 5.5 de8c543ff1 Steam Frame guides: SSH through the Frame's developer mode
Developer mode with a password for steamos in its settings turns SSH on;
the Konsole commands stay as the way without developer mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:34:43 +02:00
PodeCaradoxandClaude Opus 5.5 52f5100705 Steam Frame: the guide in English too, install.sh in the system's language
STEAM-FRAME.md is ANLEITUNG-STEAM-FRAME.md in English; the two link each
other, the README names both, and the release archive carries both.
install.sh speaks German on a German system and English elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:33:26 +02:00
PodeCaradoxandClaude Opus 5.5 4425370b05 README: name and link the project this fork comes from
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:24:37 +02:00
PodeCaradoxandClaude Opus 5.5 aafcec58a0 Steam Frame: install.sh installs from the player's disc image on the Frame
./install.sh <disc image or extracted folder> [--no-movies] extracts the
disc with Dolphin's dolphin-tool (the Flathub Flatpak, put into the user's
installation when missing: the system one asks for a password over SSH),
converts it with the converter the release archive now carries, moves the
result to ~/.local/share/GalaxyQuest/game and adds the program to Steam
with steamos-add-to-steam.  Tried on the Frame from RMGR01.iso: 2 minutes
including Dolphin, and the same files as the game data converted on a PC.
The guide starts with it, on the Frame or from a PC over SSH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:13:01 +02:00
PodeCaradoxandClaude Opus 5.5 47f12c20dc Steam Frame: the game raises its priority as far as SteamOS allows
SteamOS gives a game RLIMIT_NICE 28 (nice down to -8); the launcher takes
it before any thread starts, so every thread inherits it.  On the Frame a
chat client in the same cgroup took two cores, and the frame loop missed
up to 67 refreshes in 10 s with its own work late; with the game's
threads at -8 (tried on the running game) one or none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:58:48 +02:00
PodeCaradoxandClaude Opus 5.5 2c52f411ad Steam Frame: X is the long jump, View alone opens the pause menu
X holds Z and adds A a game frame later: the long jump while running,
the backflip standing.  Y and B spin as before.  The pause menu (+) is on
the left controller's View button only; the right one's Menu is free.
120 Hz needs no setting by hand any more (the guide said so still), and
the release archive's documents lose the executable bit a Windows drive
gives them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:32:31 +02:00
PodeCaradoxandClaude Opus 5.5 9bd10a2d81 Steam Frame: native Linux arm64 build
- Cross-build against the Steam Runtime 4 arm64 SDK (build_linux.sh,
  cmake/steamrt4-arm64.cmake): the executable sits at 0x98000000 next to
  the game's 32-bit window, with the OpenXR loader and jsoncpp beside it
  (RPATH $ORIGIN); build_linux.sh also packs a release archive.
- OpenXR on SteamVR: EGL surfaceless through XR_MNDX_egl_enable, the
  Frame controller profile (both names), a flick or a wrist turn as the
  Wii Remote's shake, the D-pad as look; the refresh rate goes to 120 Hz
  through vrcmd where the runtime offers only its current rate.
- Timewarp in the app: SteamVR shows frames that arrive in time as they
  were rendered, so at 120 Hz every game frame stood still for two
  refreshes while the head turned.  The game frame's eyes now go into a
  scene set, and every refresh composites the last finished set turned
  to its own head pose (timewarp.h, checked by tools/timewarp_test and
  by the simulator's PETARI_XRSIM_WARP); sharpening runs once per game
  frame; the pacing changes only after a new display period has held.
- Fixes found on the way: libstdc++'s <cstdio>/<cwchar> against the MSL
  renames (compat.h), language archives a disc carries damaged (cook.py
  takes another language's), host allocations on the DVD and DSP
  threads, quitting without waiting on the game's threads, case of two
  include names.
- ANLEITUNG-STEAM-FRAME.md: the player's guide in German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:26:28 +02:00
48 changed files with 2149 additions and 178 deletions

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
# GalaxyQuest auf der Steam Frame
*English: [STEAM-FRAME.md](STEAM-FRAME.md)*
Super Mario Galaxy in VR, nativ auf der **Valve Steam Frame** (SteamOS, arm64):
der Spielcode des [Petari-Projekts](https://github.com/SMGCommunity/Petari)
läuft direkt auf dem Prozessor der Frame, das Bild geht über OpenXR an
SteamVR. Es ist eine Portierung von
[GalaxyQuest](https://github.com/bigmak94/GalaxyQuest) (dort für Meta Quest);
alles über das Spiel selbst (Diorama, Riesenleinwand, VR-Einstellungen) steht
in der [README](README.md).
> **Du brauchst deine eigene Super-Mario-Galaxy-Disc.** Weder dieses
> Repository noch das Programm enthält Spieldaten. Jeder wandelt seine eigene
> Disc einmal um. Gib die umgewandelten Spieldaten nicht weiter.
## Was du brauchst
- Eine **Steam Frame** mit eingerichtetem SteamVR, etwa 9 GB frei.
- Dein **Super Mario Galaxy** als Disc-Abbild (ISO, RVZ, WBFS, GCZ, WIA),
z. B. mit [CleanRip](https://wiibrew.org/wiki/CleanRip) von deiner Disc
gezogen. Auf der Frame getestet: eine russische Fan-Übersetzung der
europäischen Disc (RMGR01; das Spiel meldet sie als RMGP01). Die
europäische (RMGP01) und die amerikanische (RMGE01) laufen in der
Quest-Fassung.
- Einen **PC** mit Linux oder Windows mit WSL2, um das Programm einmal zu
bauen. Einen fertigen Download gibt es nicht.
Den Rest macht dann `install.sh` aus dem gebauten Archiv auf der Frame: es
entpackt die Disc (mit Dolphins `dolphin-tool`; fehlt Dolphin, installiert
das Skript es aus Flathub), wandelt die Spieldateien um (einige Minuten,
3,3 GB) und trägt das Spiel in Steam ein.
## 1. Bauen (PC)
1. **Nur Windows:** PowerShell öffnen und Ubuntu in WSL2 installieren,
dann *Ubuntu 24.04* im Startmenü öffnen und alles Weitere dort:
```
wsl --install -d Ubuntu-24.04
```
2. Werkzeuge installieren (CMake 3.24+, clang 16+, lld, Ninja; getestet mit
clang 20, `sudo apt install clang-20`, falls das Standard-clang
scheitert):
```
sudo apt update
sudo apt install git clang lld cmake ninja-build python3 curl
```
3. Valves Steam-Runtime-4-SDK für arm64 herunterladen (etwa 1 GB):
```
mkdir -p ~/steamrt4-arm64-sdk/sysroot && cd ~/steamrt4-arm64-sdk
curl -O https://repo.steampowered.com/steamrt4/images/latest-public-beta/com.valvesoftware.SteamRuntime.Sdk-arm64-steamrt4-sysroot.tar.gz
tar -xzf com.valvesoftware.SteamRuntime.Sdk-arm64-steamrt4-sysroot.tar.gz -C sysroot --exclude='./dev/*'
```
4. Quellcode holen und bauen. Im Linux-Heimordner lassen, nicht auf einem
Windows-Laufwerk oder USB-Stick (dort fehlt das Recht zum Ausführen):
```
git clone https://github.com/PodeCaradox/GalaxyQuest-SteamFrame.git ~/GalaxyQuest
cd ~/GalaxyQuest
ARCH=arm64 STEAMRT4_ARM64_SYSROOT=~/steamrt4-arm64-sdk/sysroot ./build_linux.sh
```
Ergebnis: `~/GalaxyQuest/build-linux-arm64/GalaxyQuest-SteamFrame-arm64.tar.gz`
(beim ersten Mal etwa 10 Minuten; nach `git pull` wird nur Geändertes
neu gebaut). Unter Windows öffnet `explorer.exe build-linux-arm64` im
Ubuntu-Fenster den Ordner im Explorer.
## 2. Auf die Frame bringen und installieren
**Variante A – USB-Stick, ohne Netzwerk:**
1. `GalaxyQuest-SteamFrame-arm64.tar.gz` und dein Disc-Abbild (hier
`smg.iso`) auf einen USB-Stick kopieren.
2. Frame in den Desktop-Modus schalten, den Stick einstecken.
3. Im Dateimanager (Dolphin) beide Dateien vom Stick in den Persönlichen
Ordner kopieren, dann Rechtsklick auf das Archiv → *Entpacken → Hierher
entpacken*: es entsteht der Ordner `GalaxyQuest`. (Nicht auf dem Stick
entpacken: dort fehlt dem Programm das Recht, ausgeführt zu werden.)
4. Konsole öffnen und, mit dem Pfad zu deinem Disc-Abbild:
```
~/GalaxyQuest/install.sh ~/smg.iso
```
**Variante B – über das Netzwerk (SSH):**
1. Einmalig auf der Frame: *Einstellungen → System → Entwicklermodus*
einschalten und in den Entwickler-Einstellungen ein Passwort für den
Benutzer `steamos` setzen. Damit ist SSH an. (Ohne Entwicklermodus geht
es auch in der Konsole des Desktop-Modus: `passwd`, dann
`sudo systemctl enable --now sshd`.)
2. Auf dem PC (Windows-PowerShell, Linux-Terminal oder das Ubuntu-Fenster),
im Ordner mit Archiv und Disc-Abbild:
```
scp GalaxyQuest-SteamFrame-arm64.tar.gz smg.iso steamos@frame.local:
ssh steamos@frame.local "tar -xzf GalaxyQuest-SteamFrame-arm64.tar.gz && ~/GalaxyQuest/install.sh ~/smg.iso && rm ~/smg.iso"
```
Beide fragen nach dem Passwort aus Schritt 1. Wird `frame.local` nicht
gefunden (in WSL häufig), die IP-Adresse der Frame nehmen (`ip a` in der
Konsole der Frame). Im Ubuntu-Fenster liegt ein Disc-Abbild aus den
Windows-Downloads unter `/mnt/c/Users/<du>/Downloads/smg.iso`.
Ohne Prolog- und Abspann-Film (2,3 GB weniger) hinter dem Disc-Abbild
`--no-movies` anhängen. Hast du die Disc schon entpackt (Ordner mit `sys` und
`files`), geht statt des Abbilds auch dieser Ordner.
## 3. In Steam einrichten
Das Skript trägt das Spiel in Steam ein, wenn Steam läuft (sonst: Steam →
*Spiele* → *Ein Nicht-Steam-Spiel zur Bibliothek hinzufügen* → *Durchsuchen*
→ `/home/steamos/GalaxyQuest/galaxyquest`). Danach in den *Eigenschaften* des
Eintrags:
- Name: `GalaxyQuest`,
- **„In VR-Bibliothek aufnehmen“ anhaken.**
Zurück in den VR-Modus wechseln und GalaxyQuest aus der Bibliothek starten.
## 4. Ohne install.sh (von Hand)
Auf der Frame gehört das Programm nach `~/GalaxyQuest/`, der *Inhalt* des
umgewandelten Ordners nach `~/.local/share/GalaxyQuest/game/` (dort direkt
`sys` und `files`). Umwandeln geht auch auf einem PC mit Python 3.8+: Disc
mit Dolphin entpacken (Rechtsklick auf das Spiel → *Eigenschaften* →
*Dateisystem* → Rechtsklick auf die Disc → *Gesamte Disc extrahieren…*), dann
im Ordner `converter` aus dem Archiv:
```
python tools/cook/cook.py <entpackte Disc> cooked --with-movies
```
Findet das Spiel keine Daten im Heimordner, zeigt es beim Start eine Liste
der Ordner mit Spieldaten (auch auf SD-Karte und USB-Stick); dort einen
auswählen, das Spiel merkt ihn sich.
## 5. Bildwiederholrate
Das Spiel läuft mit 60 Bildern pro Sekunde und zeigt jedes genau zwei
Bildwechsel lang, wenn das Headset mit **120 Hz** läuft; beim zweiten dreht
es das Bild auf die aktuelle Kopfhaltung nach. So ist das Bild am ruhigsten,
und die Grafikkarte hat pro Auge die meiste Zeit. Das Spiel stellt 120 Hz
beim Start selbst ein: es trägt die Rate in SteamVRs anwendungsspezifische
Einstellungen ein (SteamVR → *Video*), dort lässt sie sich auch ändern.
Andere Raten: Pausenmenü → VR-Einstellungen → *Refresh rate* (72, 90, 120,
144 Hz).
## Steuerung (Frame-Controller)
| Frame-Controller | Wii | Im Spiel |
|---|---|---|
| Linker Stick | Nunchuk-Stick | Laufen |
| A | A | Springen, reden, bestätigen. Halten: Zwischensequenz oder Dialog überspringen |
| Y (oder B) | Schütteln | Drehattacke |
| X | Z halten + A | Beim Laufen Weitsprung, im Stand Rückwärtssalto |
| Rechten Controller neigen | Wii-Fernbedienung neigen | Sternenkugel und Rochen-Surfen lenken |
| Rechter Trigger | B | Sternenteile schießen, abbrechen |
| Rechten Controller zielen | Pointer | Sternenteile sammeln, Zugsterne greifen, Menüs |
| Linker Trigger | Z | Ducken; in der Luft Stampfattacke; gehalten mit A wie X |
| Linke Griff-Taste | C | Kamera hinter Mario |
| Ansicht (⧉, kleine Taste links) | + | Pausenmenü mit den VR-Einstellungen |
| Rechter Stick oder linkes Steuerkreuz, links/rechts | Steuerkreuz | Ansicht um Mario drehen |
| Rechter Stick oder linkes Steuerkreuz hoch, rechter Stick drücken | Steuerkreuz oben | Ego-Perspektive |
Die Menü-Taste (≡) rechts ist frei. Die Drehattacke geht auch, wenn man
einen Controller kurz schwingt oder aus dem Handgelenk dreht, aber nicht so
zuverlässig wie mit Y.
Ansicht neu ausrichten: mit SteamVRs eigenem „Ansicht zentrieren“ im
System-Menü; das Spiel stellt Leinwand und Diorama dann vor dich.
## Sprache
Im Pausenmenü → VR-Einstellungen → Reiter *Game* → *Language*, oder in
`~/.local/share/GalaxyQuest/petari_vr.ini` die Zeile `language = german`
(`english`, `french`, `spanish`, `italian`). Gilt ab dem nächsten Start.
Bei Fan-Übersetzungen steckt die Übersetzung meist im englischen Ordner der
Disc: bei RMGR01 ist „english“ Russisch. Solche Discs haben manchmal
beschädigte Dateien anderer Sprachen; der Konverter ersetzt dann die
betroffenen Archive durch die einer anderen Sprache und meldet das als
`note: … damaged on this disc, … used instead` (bei RMGR01 ist deshalb der
Hinweis zum Handgelenksriemen beim Start auf Niederländisch).
## Wenn etwas nicht geht
- **Protokoll:** `~/.local/share/GalaxyQuest/petari_log.txt` (das vom
vorigen Start: `petari_log.txt.prev`). Einstellungen:
`~/.local/share/GalaxyQuest/petari_vr.ini`, Spielstände:
`~/.local/share/GalaxyQuest/nand`.
- **Startet nicht aus Steam:** in der Konsole
`~/GalaxyQuest/galaxyquest` aufrufen; eine Meldung wie
`error while loading shared libraries` heißt, dass eine der drei Dateien
aus dem Archiv fehlt.
- **Liste mit Spieldaten-Ordnern statt Spiel:** die umgewandelten Daten
liegen nicht in `~/.local/share/GalaxyQuest/game` (dort muss direkt `sys`
und `files` liegen). *Not converted* heißt: noch mit `cook.py` umwandeln.
- **Ruckelt:** prüfen, ob SteamVR für GalaxyQuest 120 Hz nutzt (siehe
oben); im Protokoll zeigen Zeilen mit `missed … refreshes`, wie oft ein
Bild zu spät kam. Andere Programme im Hintergrund (etwa ein Chat-Programm,
das Video überträgt) nehmen dem Spiel Rechenzeit.
- **Bild schliert bei Kopfbewegungen:** in `petari_vr.ini` muss
`timewarp = 1` gelten (Standard): dann dreht das Spiel jedes der 120
Bilder pro Sekunde auf die aktuelle Kopfhaltung nach, was SteamVR selbst
nicht tut.
## Entwicklung
Ein Entwicklungsstand direkt auf die Frame: `tools/push_frame.sh steamos@frame.local [cooked-Ordner]`.
Was sich gegenüber der Quest-Fassung ändert, steht in
[docs/TECHNICAL.md](docs/TECHNICAL.md) unter „Steam Frame (Linux)“.
+46 -2
View File
@@ -11,7 +11,10 @@ set(DECOMP ${CMAKE_SOURCE_DIR}/decomp)
if(ANDROID)
set(PORT_PLATFORM android)
else()
set(PORT_PLATFORM desktop)
set(PORT_PLATFORM linux)
# Position-independent code: the executable sits at 0x98000000, beyond the
# reach of x86-64's absolute 32-bit addressing.
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
endif()
# ---------------------------------------------------------------------------
@@ -94,8 +97,17 @@ set(PORT_COMMON_FLAGS
-Wno-microsoft-include
-Wno-language-extension-token
-ferror-limit=0
# With -fshort-wchar, newer clang turns port_wcslen's 16-bit loop into a
# call to the C library's 32-bit wcslen.
-fno-builtin-wcslen
-include ${CMAKE_SOURCE_DIR}/platform/include/port/compat.h
)
if(NOT ANDROID AND CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64")
# JKRHeap's operator new aligns to 4 bytes; clang's SSE stores to memory it
# takes for 16-byte aligned fault on x86-64 (ARM allows them). Not 4: types
# holding a double would go to the aligned operator new, off the game heap.
list(APPEND PORT_COMMON_FLAGS -fnew-alignment=8)
endif()
set(PORT_CXX_FLAGS -fno-exceptions -fno-rtti)
set(PORT_C_FLAGS)
@@ -111,7 +123,16 @@ file(GLOB_RECURSE PLATFORM_SRCS CONFIGURE_DEPENDS
${CMAKE_SOURCE_DIR}/platform/src/*.c
${CMAKE_SOURCE_DIR}/platform/src/*.cpp)
add_library(game SHARED $<TARGET_OBJECTS:decomp> ${PLATFORM_SRCS})
if(ANDROID)
add_library(game SHARED $<TARGET_OBJECTS:decomp> ${PLATFORM_SRCS})
else()
# Linux (Steam Frame): one executable, linked where libgame.so loads on
# Android; platform/linux/main.c reserves the game's window around it.
add_library(launcher OBJECT platform/linux/main.c platform/android/headless.c)
target_include_directories(launcher PRIVATE platform/include)
add_executable(game $<TARGET_OBJECTS:decomp> $<TARGET_OBJECTS:launcher> ${PLATFORM_SRCS})
set_target_properties(game PROPERTIES OUTPUT_NAME galaxyquest)
endif()
target_include_directories(game PRIVATE ${DECOMP_INCLUDES})
target_compile_definitions(game PRIVATE ${PORT_DEFS} PORT_PLATFORM_CODE=1)
target_compile_options(game PRIVATE ${PORT_COMMON_FLAGS} $<$<COMPILE_LANGUAGE:CXX>:${PORT_CXX_FLAGS}> $<$<COMPILE_LANGUAGE:C>:${PORT_C_FLAGS}>)
@@ -123,6 +144,29 @@ if(ANDROID)
target_link_libraries(game PRIVATE log android)
endif()
if(NOT ANDROID)
# The OpenXR loader is shipped next to the executable ($ORIGIN), with the
# libjsoncpp.so.26 it needs (SteamOS has .25). An RPATH, unlike a RUNPATH,
# also covers the loader's own dependencies.
find_library(OPENXR_LOADER NAMES openxr_loader REQUIRED)
target_link_options(game PRIVATE -fuse-ld=lld -no-pie -Wl,--image-base=0x98000000 -Wl,--disable-new-dtags -Wl,-rpath,$ORIGIN)
target_link_libraries(game PRIVATE ${OPENXR_LOADER} EGL GLESv2 asound pthread dl m)
if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64")
# x86-64's non-PIE start files address absolutely below 2 GB; -B swaps in
# the PIE ones, which work at 0x98000000 (a PC build, for testing).
set(PIE_CRT ${CMAKE_BINARY_DIR}/pie-crt)
file(MAKE_DIRECTORY ${PIE_CRT})
foreach(pair crt1.o:Scrt1.o crtbegin.o:crtbeginS.o crtend.o:crtendS.o)
string(REPLACE ":" ";" pair ${pair})
list(GET pair 0 name)
list(GET pair 1 pie)
execute_process(COMMAND ${CMAKE_CXX_COMPILER} -print-file-name=${pie} OUTPUT_VARIABLE path OUTPUT_STRIP_TRAILING_WHITESPACE)
file(CREATE_LINK ${path} ${PIE_CRT}/${name} SYMBOLIC)
endforeach()
target_link_options(game PRIVATE -B${PIE_CRT})
endif()
endif()
if(ANDROID)
# The Khronos OpenXR loader (tools/fetch_openxr.sh puts it there).
set(OPENXR_DIR ${CMAKE_SOURCE_DIR}/third_party/openxr/prefab/modules)
+127 -4
View File
@@ -10,6 +10,133 @@
# GalaxyQuest
## Steam Frame port
This is a fork of [bigmak94/GalaxyQuest](https://github.com/bigmak94/GalaxyQuest)
that also runs natively on Valve's **Steam Frame** (SteamOS, arm64).
How to build and install it is below; controls, settings and
troubleshooting are in [STEAM-FRAME.md](STEAM-FRAME.md) (English) and
[ANLEITUNG-STEAM-FRAME.md](ANLEITUNG-STEAM-FRAME.md) (Deutsch).
> **Notice.** This is an unofficial, non-commercial fan project. It is not
> affiliated with, endorsed by or sponsored by Nintendo. Super Mario Galaxy
> and all related names are trademarks of Nintendo. This repository contains
> **no game data** (no disc image, no textures, models, sounds, music or
> movies) and no ready-made builds. To play you need your own, legally obtained
> Super Mario Galaxy disc, which you convert yourself. Do not share
> converted game files, and do not ask for them here.
>
> Rights holders who want something removed: please open an issue on this
> repository, and it will be taken down promptly.
There are no ready-made builds: you build the Frame version yourself on a
PC (about 10 minutes the first time), copy it to the Frame together with
your disc image, and run `install.sh` there. `install.sh` extracts the disc
with Dolphin (installed from Flathub if missing), converts the game files
and adds the game to Steam.
### 1. Build it (PC)
The Frame version is cross-compiled on a PC running Linux, or Windows with
WSL2, against Valve's Steam Runtime 4 SDK for arm64.
1. **Windows only:** open PowerShell and install Ubuntu in WSL2, then open
*Ubuntu 24.04* from the Start menu and do the rest there:
```
wsl --install -d Ubuntu-24.04
```
2. Install the tools (CMake 3.24+, clang 16+, lld, Ninja; tested with
clang 20, `sudo apt install clang-20` if the default one fails):
```
sudo apt update
sudo apt install git clang lld cmake ninja-build python3 curl
```
3. Download Valve's Steam Runtime 4 SDK sysroot for arm64 (about 1 GB):
```
mkdir -p ~/steamrt4-arm64-sdk/sysroot && cd ~/steamrt4-arm64-sdk
curl -O https://repo.steampowered.com/steamrt4/images/latest-public-beta/com.valvesoftware.SteamRuntime.Sdk-arm64-steamrt4-sysroot.tar.gz
tar -xzf com.valvesoftware.SteamRuntime.Sdk-arm64-steamrt4-sysroot.tar.gz -C sysroot --exclude='./dev/*'
```
4. Get the source and build it. Keep it in the Linux home folder, not on a
Windows drive or a USB stick (they lose the executable bit):
```
git clone https://github.com/PodeCaradox/GalaxyQuest-SteamFrame.git ~/GalaxyQuest
cd ~/GalaxyQuest
ARCH=arm64 STEAMRT4_ARM64_SYSROOT=~/steamrt4-arm64-sdk/sysroot ./build_linux.sh
```
The result is `~/GalaxyQuest/build-linux-arm64/GalaxyQuest-SteamFrame-arm64.tar.gz`:
the program with the OpenXR loader and jsoncpp next to it, `install.sh`,
the disc converter and the guides. Later builds (after `git pull`) only
rebuild what changed.
On Windows, `explorer.exe build-linux-arm64` in the Ubuntu window opens
that folder in Explorer, to copy the file out.
### 2. Bring it to the Frame and install
**Option A – USB stick, no network setup:**
1. Copy `GalaxyQuest-SteamFrame-arm64.tar.gz` and your disc image (here
`smg.iso`) to a USB stick.
2. On the Frame, switch to desktop mode and plug in the stick.
3. In the file manager (Dolphin), copy both files from the stick into your
home folder, then right-click the archive → *Extract → Extract archive
here*. (Not on the stick itself: the program would not be allowed to run
from there.)
4. Open Konsole and run:
```
~/GalaxyQuest/install.sh ~/smg.iso
```
**Option B – over the network (SSH):**
1. Once, on the Frame: turn on *Settings → System → Developer Mode* and set
a password for the user `steamos` in the developer settings. That turns
SSH on.
2. On the PC, in the folder with the archive and the disc image:
```
scp GalaxyQuest-SteamFrame-arm64.tar.gz smg.iso steamos@frame.local:
ssh steamos@frame.local "tar -xzf GalaxyQuest-SteamFrame-arm64.tar.gz && ~/GalaxyQuest/install.sh ~/smg.iso && rm ~/smg.iso"
```
Both ask for the password from step 1. If `frame.local` is not found
(often the case inside WSL), use the Frame's IP address instead
(`ip a` in Konsole on the Frame shows it). From the Ubuntu window the
build is at `build-linux-arm64/GalaxyQuest-SteamFrame-arm64.tar.gz` and
a disc image in the Windows downloads at
`/mnt/c/Users/<you>/Downloads/smg.iso`.
Add `--no-movies` after the disc image to leave out the prologue and ending
movies (2.3 GB less).
### 3. Start it
`install.sh` adds the game to Steam when Steam is running (otherwise:
Steam → *Games* → *Add a Non-Steam Game to My Library…* → *Browse* →
`/home/steamos/GalaxyQuest/galaxyquest`). In the entry's *Properties*,
rename it to `GalaxyQuest` and **tick "Include in VR Library"**, then start
it from the library in VR mode.
Controls, settings and troubleshooting: [STEAM-FRAME.md](STEAM-FRAME.md)
(English), [ANLEITUNG-STEAM-FRAME.md](ANLEITUNG-STEAM-FRAME.md) (Deutsch).
What the port changes compared with the Quest version is described in
[docs/TECHNICAL.md](docs/TECHNICAL.md) under "Steam Frame (Linux)".
---
*Everything below is the original project's README.*
**Play Super Mario Galaxy in virtual reality, natively on Meta Quest 2 and 3.**
Mario's universe becomes a living diorama in front of you: planets float at
@@ -23,8 +150,6 @@ is compiled for the headset's processor. A new platform layer stands in for
the Wii's hardware (graphics, sound, controllers, saves) and presents the
game in VR through OpenXR.
![Mario on a pirate ship in the diorama view](docs/images/diorama.jpg)
> **You need your own copy of Super Mario Galaxy.** Neither this repository
> nor the app contains any game data. You convert your own disc once (its
> files, and the few pieces of data its program holds), copy the result to
@@ -82,8 +207,6 @@ resolution.
**Everything else is the original game.** Levels, physics, enemies, music,
story and saves all come from the game's own code and your own game files.
![The pause menu with the VR settings panel](docs/images/pause_settings.jpg)
## What you need
- A **Meta Quest 2 or 3** (both tested and working; a Quest 3S should work
+216
View File
@@ -0,0 +1,216 @@
# GalaxyQuest on the Steam Frame
*Deutsch: [ANLEITUNG-STEAM-FRAME.md](ANLEITUNG-STEAM-FRAME.md)*
Super Mario Galaxy in VR, running natively on the **Valve Steam Frame**
(SteamOS, arm64): the game code of the
[Petari project](https://github.com/SMGCommunity/Petari) runs directly on
the Frame's processor, and the picture goes to SteamVR through OpenXR. It is
a port of [GalaxyQuest](https://github.com/bigmak94/GalaxyQuest) (made for
Meta Quest there); everything about the game itself (diorama, giant screen,
VR settings) is in the [README](README.md).
> **You need your own Super Mario Galaxy disc.** Neither this repository nor
> the program contains any game data. Everyone converts their own disc once.
> Do not share the converted game files.
## What you need
- A **Steam Frame** with SteamVR set up, and about 9 GB free.
- Your **Super Mario Galaxy** as a disc image (ISO, RVZ, WBFS, GCZ, WIA),
for example dumped from your disc with
[CleanRip](https://wiibrew.org/wiki/CleanRip). Tested on the Frame: a
Russian fan translation of the European disc (RMGR01; the game reports it
as RMGP01). The European (RMGP01) and American (RMGE01) discs run in the
Quest version.
- A **PC** with Linux, or Windows with WSL2, to build the program once.
There are no ready-made builds.
`install.sh` from the built archive then does the rest on the Frame: it
extracts the disc (with Dolphin's `dolphin-tool`; if Dolphin is missing, the
script installs it from Flathub), converts the game files (a few minutes,
3.3 GB) and adds the game to Steam.
## 1. Build it (PC)
1. **Windows only:** open PowerShell and install Ubuntu in WSL2, then open
*Ubuntu 24.04* from the Start menu and do the rest there:
```
wsl --install -d Ubuntu-24.04
```
2. Install the tools (CMake 3.24+, clang 16+, lld, Ninja; tested with
clang 20, `sudo apt install clang-20` if the default one fails):
```
sudo apt update
sudo apt install git clang lld cmake ninja-build python3 curl
```
3. Download Valve's Steam Runtime 4 SDK sysroot for arm64 (about 1 GB):
```
mkdir -p ~/steamrt4-arm64-sdk/sysroot && cd ~/steamrt4-arm64-sdk
curl -O https://repo.steampowered.com/steamrt4/images/latest-public-beta/com.valvesoftware.SteamRuntime.Sdk-arm64-steamrt4-sysroot.tar.gz
tar -xzf com.valvesoftware.SteamRuntime.Sdk-arm64-steamrt4-sysroot.tar.gz -C sysroot --exclude='./dev/*'
```
4. Get the source and build it. Keep it in the Linux home folder, not on a
Windows drive or a USB stick (they lose the executable bit):
```
git clone https://github.com/PodeCaradox/GalaxyQuest-SteamFrame.git ~/GalaxyQuest
cd ~/GalaxyQuest
ARCH=arm64 STEAMRT4_ARM64_SYSROOT=~/steamrt4-arm64-sdk/sysroot ./build_linux.sh
```
The result is `~/GalaxyQuest/build-linux-arm64/GalaxyQuest-SteamFrame-arm64.tar.gz`
(about 10 minutes the first time; after `git pull` only what changed is
rebuilt). On Windows, `explorer.exe build-linux-arm64` in the Ubuntu
window opens that folder in Explorer.
## 2. Bring it to the Frame and install
**Option A – USB stick, no network setup:**
1. Copy `GalaxyQuest-SteamFrame-arm64.tar.gz` and your disc image (here
`smg.iso`) to a USB stick.
2. On the Frame, switch to desktop mode and plug in the stick.
3. In the file manager (Dolphin), copy both files from the stick into your
home folder, then right-click the archive → *Extract → Extract archive
here*. This makes the folder `GalaxyQuest`. (Not on the stick itself:
the program would not be allowed to run from there.)
4. Open Konsole and run, with the path to your disc image:
```
~/GalaxyQuest/install.sh ~/smg.iso
```
**Option B – over the network (SSH):**
1. Once, on the Frame: turn on *Settings → System → Developer Mode* and set
a password for the user `steamos` in the developer settings. That turns
SSH on. (Without developer mode, Konsole in desktop mode does it too:
`passwd`, then `sudo systemctl enable --now sshd`.)
2. On the PC (Windows PowerShell, a Linux terminal or the Ubuntu window),
in the folder with the archive and the disc image:
```
scp GalaxyQuest-SteamFrame-arm64.tar.gz smg.iso steamos@frame.local:
ssh steamos@frame.local "tar -xzf GalaxyQuest-SteamFrame-arm64.tar.gz && ~/GalaxyQuest/install.sh ~/smg.iso && rm ~/smg.iso"
```
Both ask for the password from step 1. If `frame.local` is not found
(often the case inside WSL), use the Frame's IP address (`ip a` in
Konsole on the Frame). From the Ubuntu window a disc image in the
Windows downloads is at `/mnt/c/Users/<you>/Downloads/smg.iso`.
Add `--no-movies` after the disc image to leave out the prologue and ending
movies (2.3 GB less). If you already extracted the disc (a folder with
`sys` and `files`), you can give that folder instead of the image.
## 3. Set it up in Steam
The script adds the game to Steam if Steam is running (otherwise: Steam →
*Games* → *Add a Non-Steam Game to My Library…* → *Browse* →
`/home/steamos/GalaxyQuest/galaxyquest`). Then, in the entry's
*Properties*:
- name: `GalaxyQuest`,
- **tick "Include in VR Library".**
Switch back to VR mode and start GalaxyQuest from the library.
## 4. Without install.sh (by hand)
On the Frame the program goes to `~/GalaxyQuest/`, the *contents* of the
converted folder to `~/.local/share/GalaxyQuest/game/` (with `sys` and
`files` directly inside). The conversion also works on a PC with Python
3.8+: extract the disc with Dolphin (right-click the game → *Properties* →
*Filesystem* → right-click the disc → *Extract Entire Disc…*), then, in the
archive's `converter` folder:
```
python tools/cook/cook.py <extracted disc> cooked --with-movies
```
If the game finds no data in the home folder, it shows a list of folders
with game data on start (also on SD cards and USB sticks); pick one there
and the game remembers it.
## 5. Refresh rate
The game runs at 60 frames a second and shows each one for exactly two
refreshes when the headset runs at **120 Hz**; on the second it turns the
picture to where your head points now. That gives the steadiest picture and
the GPU the most time per eye. The game sets 120 Hz itself when it starts:
it writes the rate into SteamVR's per-application settings (SteamVR →
*Video*), where it can also be changed. Other rates: pause menu → VR
settings → *Refresh rate* (72, 90, 120, 144 Hz).
## Controls (Frame controllers)
| Frame controller | Wii | In the game |
|---|---|---|
| Left stick | Nunchuk stick | Move |
| A | A | Jump, talk, confirm. Hold: skip a cutscene or dialogue |
| Y (or B) | Shake | Spin |
| X | Hold Z + A | Long jump while running, backflip standing still |
| Tilt the right controller | Tilt the Wii Remote | Steer the star ball and the manta ray |
| Right trigger | B | Shoot star bits, cancel |
| Point the right controller | Pointer | Collect star bits, grab pull stars, menus |
| Left trigger | Z | Crouch; ground pound in the air; held with A same as X |
| Left grip | C | Camera behind Mario |
| View (⧉, small button on the left) | + | Pause menu with the VR settings |
| Right stick or left D-pad, left/right | D-pad | Turn the view around Mario |
| Right stick or left D-pad up, right stick click | D-pad up | First-person view |
The Menu button (≡) on the right does nothing. Spinning also works by
swinging a controller briefly or flicking your wrist, but not as reliably
as with Y.
Recentre the view with SteamVR's own "Recenter view" in the system menu;
the game then puts the screen and the diorama in front of you.
## Language
Pause menu → VR settings → *Game* tab → *Language*, or the line
`language = german` in `~/.local/share/GalaxyQuest/petari_vr.ini`
(`english`, `french`, `spanish`, `italian`). Takes effect on the next start.
On fan translations the translation usually sits in the disc's English
folder: on RMGR01, "english" is Russian. Such discs sometimes carry damaged
files of other languages; the converter then replaces the affected archives
with another language's and reports it as
`note: … damaged on this disc, … used instead` (that is why the wrist strap
notice at start is in Dutch on RMGR01).
## Troubleshooting
- **Log:** `~/.local/share/GalaxyQuest/petari_log.txt` (the one from the
start before: `petari_log.txt.prev`). Settings:
`~/.local/share/GalaxyQuest/petari_vr.ini`, saves:
`~/.local/share/GalaxyQuest/nand`.
- **Does not start from Steam:** run `~/GalaxyQuest/galaxyquest` in
Konsole; a message like `error while loading shared libraries` means one
of the three files from the archive is missing.
- **A list of game data folders instead of the game:** the converted data
is not in `~/.local/share/GalaxyQuest/game` (`sys` and `files` must be
directly inside). *Not converted* means: still to be converted with
`cook.py` (or `install.sh`).
- **Stutters:** check that SteamVR uses 120 Hz for GalaxyQuest (see above);
lines with `missed … refreshes` in the log show how often a frame came
late. Other apps busy in the background (a chat client streaming video,
for example) take CPU time from the game.
- **The picture smears when you turn your head:** `timewarp = 1` (the
default) must be set in `petari_vr.ini`: the game then turns each of the
120 pictures a second to your current head pose, which SteamVR itself does
not do.
## Development
A development build straight to the Frame: `tools/push_frame.sh steamos@frame.local [cooked folder]`.
What differs from the Quest version is in
[docs/TECHNICAL.md](docs/TECHNICAL.md) under "Steam Frame (Linux)".
+32
View File
@@ -145,6 +145,38 @@ The app ships `libopenxr_loader.so` from `openxr_loader_for_android` 1.1.63
which `tools/fetch_openxr.sh` downloads from Maven Central, and is compiled
against its headers. Licensed under the Apache License 2.0 (below).
## Khronos OpenXR loader and JsonCpp for the Steam Frame
The Linux build for the Steam Frame ships `libopenxr_loader.so.1` 1.1.47
(The Khronos Group Inc., licensed under the Apache License 2.0, below) and the
`libjsoncpp.so.26` 1.9.6 it needs, both as built by Debian for Valve's Steam
Runtime 4 arm64 SDK, and is compiled against the loader's headers. JsonCpp is
licensed under the MIT License:
```
Copyright (c) 2007-2010 Baptiste Lepilleur and The JsonCpp Authors
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Android NDK native app glue
The app's launcher (`libmain.so`) is built with `android_native_app_glue.c`
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Configures and builds the Linux target: build-linux-<arch>/galaxyquest.
# ./build_linux.sh this machine (a PC, to test)
# ARCH=arm64 STEAMRT4_ARM64_SYSROOT=<sysroot> ./build_linux.sh
# the Steam Frame, against Valve's Steam
# Runtime 4 arm64 SDK sysroot; also packs
# GalaxyQuest-SteamFrame-arm64.tar.gz
# Needs clang 16+, lld, CMake 3.24+ and Ninja; natively also the EGL, GLES,
# ALSA and OpenXR loader development files.
set -e
cd "$(dirname "$0")"
ARCH=${ARCH:-native}
BUILD=${BUILD:-build-linux-$ARCH}
if [ "$ARCH" = arm64 ]; then
# Every time: CMake reads it again whenever it configures anew.
[ -d "$STEAMRT4_ARM64_SYSROOT/usr" ] || { echo "set STEAMRT4_ARM64_SYSROOT to the arm64 SDK sysroot" >&2; exit 1; }
fi
if [ ! -f $BUILD/build.ninja ]; then
args=()
if [ "$ARCH" = arm64 ]; then
args+=(-DCMAKE_TOOLCHAIN_FILE=cmake/steamrt4-arm64.cmake)
fi
cmake -S . -B $BUILD -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_BUILD_TYPE=${CONFIG:-RelWithDebInfo} "${args[@]}"
fi
ninja -C $BUILD "$@"
if [ "$ARCH" = arm64 ]; then
# The OpenXR loader and its JSON library go next to the executable (rpath
# $ORIGIN): SteamOS has neither in these versions.
for lib in libopenxr_loader.so.1 libjsoncpp.so.26; do
cp -L "$STEAMRT4_ARM64_SYSROOT/usr/lib/aarch64-linux-gnu/$lib" $BUILD/
done
# The release download: the three files in a GalaxyQuest folder, with the
# executable bit kept (ANLEITUNG-STEAM-FRAME.md).
rm -rf $BUILD/package
mkdir -p $BUILD/package/GalaxyQuest
cp $BUILD/galaxyquest $BUILD/libopenxr_loader.so.1 $BUILD/libjsoncpp.so.26 LICENSE THIRD_PARTY_NOTICES.md ANLEITUNG-STEAM-FRAME.md STEAM-FRAME.md \
platform/linux/install.sh $BUILD/package/GalaxyQuest/
# The disc converter for install.sh, as the converter zip holds it.
python3 tools/package_converter.py $BUILD/converter.zip > /dev/null
python3 -m zipfile -e $BUILD/converter.zip $BUILD/package/GalaxyQuest/
mv $BUILD/package/GalaxyQuest/GalaxyQuest-converter $BUILD/package/GalaxyQuest/converter
rm $BUILD/package/GalaxyQuest/converter/README.txt # the Quest's steps; install.sh does them here
find $BUILD/package -type f -exec chmod 644 {} + # copies from a Windows drive come executable
chmod 755 $BUILD/package/GalaxyQuest/galaxyquest $BUILD/package/GalaxyQuest/install.sh
tar -C $BUILD/package --owner=0 --group=0 -czf $BUILD/GalaxyQuest-SteamFrame-arm64.tar.gz GalaxyQuest
fi
+29
View File
@@ -0,0 +1,29 @@
# Cross-compiles the Linux build for the Steam Frame (arm64) with clang and lld
# against Valve's Steam Runtime 4 arm64 SDK sysroot (STEAMRT4_ARM64_SYSROOT).
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
# Kept in the cache: CMake reads this file again whenever it configures anew,
# also when Ninja re-runs it without the variable set.
if(DEFINED ENV{STEAMRT4_ARM64_SYSROOT})
set(STEAMRT4_ARM64_SYSROOT "$ENV{STEAMRT4_ARM64_SYSROOT}" CACHE PATH "Steam Runtime 4 arm64 SDK sysroot" FORCE)
endif()
set(CMAKE_SYSROOT "${STEAMRT4_ARM64_SYSROOT}")
set(CMAKE_C_COMPILER clang)
set(CMAKE_CXX_COMPILER clang++)
set(CMAKE_C_COMPILER_TARGET aarch64-linux-gnu)
set(CMAKE_CXX_COMPILER_TARGET aarch64-linux-gnu)
# GCC (libstdc++, start files) from usr/lib rather than through the sysroot's
# lib -> usr/lib link: the header paths in the dependency files then exist as
# written, and Ninja rebuilds only what changed.
file(GLOB _gcc_dirs LIST_DIRECTORIES true "${CMAKE_SYSROOT}/usr/lib/gcc/aarch64-linux-gnu/*")
if(_gcc_dirs)
list(SORT _gcc_dirs COMPARE NATURAL)
list(GET _gcc_dirs -1 _gcc_dir)
set(CMAKE_C_FLAGS_INIT "--gcc-install-dir=${_gcc_dir}")
set(CMAKE_CXX_FLAGS_INIT "--gcc-install-dir=${_gcc_dir}")
endif()
set(CMAKE_EXE_LINKER_FLAGS_INIT -fuse-ld=lld)
set(CMAKE_SHARED_LINKER_FLAGS_INIT -fuse-ld=lld)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+1 -1
View File
@@ -20,7 +20,7 @@
#include "Game/Enemy/KameckBeam.hpp"
#include "Game/Enemy/KameckBeamHolder.hpp"
#include "Game/Enemy/KameckHolder.hpp"
#include "Game/Enemy/KariKariDirector.hpp"
#include "Game/Enemy/KarikariDirector.hpp"
#include "Game/Enemy/Karon.hpp"
#include "Game/Enemy/Kuribo.hpp"
#include "Game/Enemy/Meramera.hpp"
@@ -2,8 +2,8 @@
#define RVL_FACE_LIBRARY_INTERNAL_DATABASE_H
#include "RFLi_HiddenDatabase.h"
#include "RFLi_Types.h"
#include <revolution/MEM.h>
#include <revolution/OS.h>
#include <revolution/mem.h>
#include <revolution/os.h>
#include <revolution/types.h>
#ifdef __cplusplus
extern "C" {
@@ -1,9 +1,9 @@
#ifndef RVL_FACE_LIBRARY_INTERNAL_NAND_ACCESS_H
#define RVL_FACE_LIBRARY_INTERNAL_NAND_ACCESS_H
#include "RFLi_Types.h"
#include <revolution/FS.h>
#include <revolution/MEM.h>
#include <revolution/NAND.h>
#include <revolution/fs.h>
#include <revolution/mem.h>
#include <revolution/nand.h>
#include <revolution/types.h>
#ifdef __cplusplus
extern "C" {
+1 -1
View File
@@ -7,7 +7,7 @@
#include "RFLi_NANDAccess.h"
#include "RFLi_NANDLoader.h"
#include "RFLi_Types.h"
#include <revolution/MEM.h>
#include <revolution/mem.h>
#include <revolution/types.h>
#ifdef __cplusplus
extern "C" {
@@ -2,8 +2,8 @@
#define RVL_SDK_WUD_H
#include "revolution/types.h"
#include "revolution/BTE.h"
#include "revolution/SC.h"
#include "revolution/bte.h"
#include "revolution/sc.h"
#ifdef __cplusplus
extern "C" {
#endif
+2
View File
@@ -228,9 +228,11 @@ namespace NrvMeramera {
NEW_NERVE(MerameraNrvReadyRestart, Meramera, ReadyRestart);
} // namespace NrvMeramera
#ifndef TARGET_PC // unused, and libstdc++'s <math.h> puts C++20's std::lerp in this namespace
f32 lerp(f32 start, f32 end, f32 t) {
return start + (end - start) * t;
}
#endif
inline bool Meramera::isSensor(const HitSensor* pSensor, const char* pName) const {
return getSensor(pName) == pSensor;
@@ -1,4 +1,4 @@
#include "Game/Enemy/KariKariDirector.hpp"
#include "Game/Enemy/KarikariDirector.hpp"
#include "Game/MapObj/BlackHole.hpp"
#include "Game/Player/MarioActor.hpp"
#include "Game/Player/MarioConst.hpp"
+1 -1
View File
@@ -1,4 +1,4 @@
#include "Game/Enemy/KariKariDirector.hpp"
#include "Game/Enemy/KarikariDirector.hpp"
#include "Game/LiveActor/HitSensor.hpp"
#include "Game/LiveActor/LiveActor.hpp"
#include "Game/Map/HitInfo.hpp"
+1 -1
View File
@@ -1,5 +1,5 @@
#include "Game/Player/MarioSlider.hpp"
#include "Game/Enemy/KariKariDirector.hpp"
#include "Game/Enemy/KarikariDirector.hpp"
#include "Game/Map/HitInfo.hpp"
#include "Game/Player/Mario.hpp"
#include "Game/Player/MarioActor.hpp"
@@ -47,6 +47,11 @@ void FileLoaderThread::loadToMainRAM(RequestFileInfo* pInfo) {
void FileLoaderThread::mountArchiveAndStartCreateResource(RequestFileInfo* pInfo) {
pInfo->_88 = 1;
void* data = ::loadFileUsingRipper(pInfo);
#ifdef TARGET_PC
if (data == nullptr) {
OSReport("FileLoaderThread: archive %s did not load\n", pInfo->mFileName);
}
#endif
MR::createAndAddArchive(data, pInfo->mFileEntry->mHeap, pInfo->mFileName);
pInfo->mFileEntry->setContext(data, pInfo->mFileEntry->mHeap);
pInfo->_88 = 2;
+6
View File
@@ -78,6 +78,9 @@ void* FileRipper::loadToMainRAM(const char* fpath, u8* dest, bool decompress, JK
alloced = true;
}
if (!dest) {
#ifdef TARGET_PC
OSReport("FileRipper: no room for %s (%u bytes unpacked)\n", fpath, decompressedSize);
#endif
return 0;
}
} else {
@@ -87,6 +90,9 @@ void* FileRipper::loadToMainRAM(const char* fpath, u8* dest, bool decompress, JK
alloced = true;
}
if (!dest) {
#ifdef TARGET_PC
OSReport("FileRipper: no room for %s (%u bytes)\n", fpath, fsize);
#endif
return 0;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
#include "Game/Util/ActorMovementUtil.hpp"
#include "Game/Animation/Xanimecore.hpp"
#include "Game/Animation/XanimeCore.hpp"
#include "Game/LiveActor/Binder.hpp"
#include "Game/LiveActor/HitSensor.hpp"
#include "Game/LiveActor/LiveActor.hpp"
+5
View File
@@ -1585,7 +1585,12 @@ namespace MR {
}
bool isNan(const TVec3f& rVec) {
#ifdef TARGET_PC
// 1 is MSL's FP_NAN; the host C libraries number the classes otherwise.
if (__builtin_isnan(rVec.x) || __builtin_isnan(rVec.y) || __builtin_isnan(rVec.z)) {
#else
if (__fpclassifyf(rVec.x) == 1 || __fpclassifyf(rVec.y) == 1 || __fpclassifyf(rVec.z) == 1) {
#endif
return true;
}
+1 -1
View File
@@ -1,5 +1,5 @@
#include "RVLFaceLibInternal.h"
#include <revolution/NAND.h>
#include <revolution/nand.h>
#include <cstdio>
#define LOADER_HEADER_BUF_1_SIZE 0x100
+1 -1
View File
@@ -1,4 +1,4 @@
#include "revolution/DVD.h"
#include "revolution/dvd.h"
#include "revolution/nwc24.h"
#include "revolution/nwc24/NWC24Internal.h"
#include "revolution/os.h"
+1 -1
View File
@@ -3,7 +3,7 @@
#include "revolution/dvd.h"
#include "revolution/os.h"
#include "revolution/os/OSExecParams.h"
#include "revolution/os/OSTIme.h"
#include "revolution/os/OSTime.h"
#include "revolution/types.h"
#include <wstring.h>
+1 -1
View File
@@ -1,4 +1,4 @@
#include "revolution/OS.h"
#include "revolution/os.h"
#include "revolution/wud/WUDInternal.h"
#include <cstddef>
+70
View File
@@ -460,6 +460,76 @@ In the diorama:
tabs are tables of rows over those keys (a switch, a stepper or a
slider). The panel writes only the lines of the settings changed on it.
## Steam Frame (Linux)
The same code runs natively on Valve's Steam Frame (SteamOS, arm64, SteamVR);
[ANLEITUNG-STEAM-FRAME.md](../ANLEITUNG-STEAM-FRAME.md) has the player's
steps (in German).
- One executable, `galaxyquest`, linked at 0x98000000 (libgame.so's slot on
Android). `platform/linux/main.c` reserves the rest of the window
0x80000000-0xE0010000 from `.preinit_array`, before any library's
constructor can start the heap there, and runs the bring-up runner with
`--headless <data root> <save root> [s]`. It is cross-compiled with clang
against Valve's Steam Runtime 4 arm64 SDK sysroot (`build_linux.sh`,
`cmake/steamrt4-arm64.cmake`).
- The OpenXR loader and the `libjsoncpp.so.26` it needs (SteamOS has .25)
ship next to the executable and are found through an RPATH: unlike a
RUNPATH it also covers the loader's own dependency. Steam runs a native
non-Steam shortcut on the host, not in the Steam Linux Runtime.
- Graphics: EGL on Mesa's surfaceless platform (the Frame's GL is zink on
Turnip), handed to SteamVR with `XR_MNDX_egl_enable`. SteamVR's GLES
swapchains are `GL_SRGB8_ALPHA8` with one mip level (the screen layer's
mipmaps are ignored); without `GL_RGBA16F` and depth formats SpaceWarp stays
off.
- Refresh rate: SteamVR's `XR_FB_display_refresh_rate` lists only the rate the
display runs at and accepts only that one. SteamVR picks it per app from its
settings (`steam.app.<id>.preferredRefreshRate`, or
`system.generated.openxr.galaxyquest.galaxyquest` when not started from
Steam): 120 Hz gives the paired mode of `renderFrame`.
- Timewarp: SteamVR shows an eye image as it was rendered, where Meta's
compositor turns it to the head's pose of the refresh it goes out on. In
the paired mode each game frame stayed on for two refreshes as rendered,
so the view stepped at 60 Hz while the head turned: a smear on every head
movement. So the app does it (`timewarp`, on by default): the paired
refreshes render the game frame's eyes into one of two scene sets
(`vr::renderEyeScene`, one eye a refresh as before), and every refresh
composites the set finished last into the eye images for that refresh's
pose (`vr::compositeEye`): the composite's full-screen triangle carries a
homography (`uWarp`) from the image's uv to the scene's, a rotation from
the rendered eye to the shown one between the two fields of view. Only
the rotation is made up for; the eyes' few millimetres of travel are not.
`PETARI_XRSIM_WARP=<deg>` checks it in the headless simulator against a
picture rendered at the turned pose.
- Input: the Frame's controllers through `XR_VALVE_frame_controller_interaction`
(`/interaction_profiles/valve/frame_controller_valve`, and its older name
`.../frame_controller`; the runtime accepts their paths only with the
extension enabled): A, B, X, Y and Menu on the right, a D-pad and View on
the left. B and Y spin; X holds Z and adds A a game frame later, the long
jump while running (the backflip standing); View alone opens the pause
menu (+), the right Menu is left free. The D-pad works as the right stick
does. A flick of either
controller shakes the Wii Remote or the Nunchuk: its speed (from the
runtime's velocities, else from the last pose) or a quick turn of the
wrist.
- Audio: ALSA's `default` device (PipeWire on SteamOS) at 48 kHz, fed by a
thread that blocks in `snd_pcm_writei`, so the device paces the ring as
AAudio's callback does on Android.
- What glibc and libstdc++ changed: libstdc++'s `<cstdio>` #undefs compat.h's
printf renames (compat.h now includes it first); libstdc++'s deque frees
and allocates its nodes as it goes, where libc++ recycles its blocks, so
host queues filled on game threads allocate inside a `PortHostAllocScope`
(`dvd.cpp`, `ai_dsp.cpp`); glibc's `pthread_cond_destroy` waits for the
threads parked on a condition variable, so the app leaves with `_exit`;
`MR::isNan` no longer relies on MSL's numbering of `__fpclassifyf`'s
classes; `-fno-builtin-wcslen` keeps clang from turning the 16-bit
`port_wcslen` into a call to the C library's 32-bit `wcslen`.
- Files: game data, settings, log, saves and shader cache live in
`~/.local/share/GalaxyQuest` (`$GALAXYQUEST_HOME` overrides it). The setup
screen also searches `/run/media` (SD cards, USB sticks). The disc layer
keeps every file open once read (2,400 files), so `main` raises the
open-file soft limit.
## Development tools
`tools/run_headless.sh <seconds> [--no-push]` pushes the build and boots it
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

+53 -12
View File
@@ -10,8 +10,13 @@
//
// Controller 0 is a connected Wii remote + Nunchuk at rest. PETARI_INPUT
// scripts it (see port/input_script.h).
//
// On Linux the game is linked into the launcher (platform/linux/main.c),
// which reserves the window and runs this as `galaxyquest --headless ...`.
#ifdef __ANDROID__
#include <android/dlext.h>
#include <dlfcn.h>
#endif
#include <errno.h>
#include <math.h>
#include <stdint.h>
@@ -19,6 +24,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
@@ -84,16 +90,50 @@ static long long nowMs(void) {
static const uintptr_t kWindowBase = 0x80000000u;
static const size_t kWindowSize = 0x60010000u;
#ifdef __ANDROID__
static const uintptr_t kLibBase = 0x98000000u;
static const size_t kLibSize = 0x08000000u;
#define GAME_SYM(name) dlsym(lib, #name)
#else
extern int port_log_to_stderr, port_trace_nerves;
void port_mem_set_reserved_window(uintptr_t base, size_t size);
void port_boot(const char* dataRoot, const char* saveRoot);
unsigned long long port_headless_render(const char* path, int scale);
void port_headless_xrsim(const char* path, int frames, float yawDeg, float pitchDeg);
void port_headless_dump(const char* prefix);
void port_debug_dump_threads(void);
#define GAME_SYM(name) ((void*)&name)
#endif
#ifdef __ANDROID__
int main(int argc, char** argv) {
#else
int port_headless_main(int argc, char** argv) {
#endif
if (argc < 3) {
fprintf(stderr, "usage: %s <data root> <save root> [seconds]\n", argv[0]);
return 2;
}
int seconds = argc > 3 ? atoi(argv[3]) : 60;
#ifndef __ANDROID__
// The captures go beside the save root (tools/run_headless.sh makes the
// folders on the headset).
char path[512];
snprintf(path, sizeof(path), "%s", argv[2]);
for (char* p = path; *p; p++) { // the save root's parents
if (*p == '/' && p != path) {
*p = 0;
mkdir(path, 0755);
*p = '/';
}
}
mkdir(path, 0755);
snprintf(path, sizeof(path), "%s/../shots", argv[2]);
mkdir(path, 0755);
#endif
#ifdef __ANDROID__
void* win = mmap((void*)kWindowBase, kWindowSize, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED_NOREPLACE, -1, 0);
if (win != (void*)kWindowBase) {
fprintf(stderr, "cannot reserve %p: got %p errno %d\n", (void*)kWindowBase, win, errno);
@@ -115,30 +155,31 @@ int main(int argc, char** argv) {
fprintf(stderr, "dlopen %s: %s\n", path, dlerror());
return 1;
}
#endif
int* toStderr = (int*)dlsym(lib, "port_log_to_stderr");
int* toStderr = (int*)GAME_SYM(port_log_to_stderr);
if (toStderr) {
*toStderr = 1;
}
void (*setWindow)(uintptr_t, size_t) = (void (*)(uintptr_t, size_t))dlsym(lib, "port_mem_set_reserved_window");
void (*boot)(const char*, const char*) = (void (*)(const char*, const char*))dlsym(lib, "port_boot");
void (*setWindow)(uintptr_t, size_t) = (void (*)(uintptr_t, size_t))GAME_SYM(port_mem_set_reserved_window);
void (*boot)(const char*, const char*) = (void (*)(const char*, const char*))GAME_SYM(port_boot);
if (!setWindow || !boot) {
fprintf(stderr, "missing entry points: %s\n", dlerror());
fprintf(stderr, "missing entry points\n");
return 1;
}
setWindow(kWindowBase, kWindowSize);
int* traceNerves = (int*)dlsym(lib, "port_trace_nerves");
int* traceNerves = (int*)GAME_SYM(port_trace_nerves);
if (traceNerves && envSet("PETARI_NERVES")) {
*traceNerves = atoi(envSet("PETARI_NERVES"));
}
void (*inputSet)(int, const PortPadState*) = (void (*)(int, const PortPadState*))dlsym(lib, "port_input_set");
void (*inputSet)(int, const PortPadState*) = (void (*)(int, const PortPadState*))GAME_SYM(port_input_set);
static PortInputEvent events[1024];
int eventCount = envSet("PETARI_INPUT") ? portParseInputScript(envSet("PETARI_INPUT"), events, 1024) : 0;
PortPadState pad;
padAt(events, eventCount, 0, &pad);
if (inputSet) inputSet(0, &pad);
fprintf(stderr, "libgame.so loaded at %p; booting\n", (void*)kLibBase);
fprintf(stderr, "game at %p; booting\n", (void*)0x98000000u);
long long start = nowMs();
// The boot time, for debug hooks timed like PETARI_INPUT (PETARI_WARP).
char startText[32];
@@ -148,7 +189,7 @@ int main(int argc, char** argv) {
// Feed the scripted input every 5 ms and render a snapshot of the latest
// game frame every PETARI_SHOT_MS (default 2 s).
unsigned long long (*render)(const char*, int) = (unsigned long long (*)(const char*, int))dlsym(lib, "port_headless_render");
unsigned long long (*render)(const char*, int) = (unsigned long long (*)(const char*, int))GAME_SYM(port_headless_render);
char shot[512];
int shotIndex = 0;
int intervalMs = envSet("PETARI_SHOT_MS") ? atoi(envSet("PETARI_SHOT_MS")) : 2000;
@@ -162,7 +203,7 @@ int main(int argc, char** argv) {
// that rate between snapshots (profiling with PETARI_PERFLOG=1).
double xrsimFps = envSet("PETARI_XRSIM_FPS") ? atof(envSet("PETARI_XRSIM_FPS")) : 0.0;
long long nextXrsim = start;
void (*xrsimFn)(const char*, int, float, float) = (void (*)(const char*, int, float, float))dlsym(lib, "port_headless_xrsim");
void (*xrsimFn)(const char*, int, float, float) = (void (*)(const char*, int, float, float))GAME_SYM(port_headless_xrsim);
uint32_t lastButtons = 0;
for (;;) {
long long now = nowMs();
@@ -194,7 +235,7 @@ int main(int argc, char** argv) {
render(shot, 2);
if (envSet("PETARI_XRSIM")) {
// The headset presentation (vr_game.cpp) with simulated eyes.
void (*xrsim)(const char*, int, float, float) = (void (*)(const char*, int, float, float))dlsym(lib, "port_headless_xrsim");
void (*xrsim)(const char*, int, float, float) = (void (*)(const char*, int, float, float))GAME_SYM(port_headless_xrsim);
float yaw = 0, pitch = -20;
if (envSet("PETARI_XRHEAD")) sscanf(envSet("PETARI_XRHEAD"), "%f,%f", &yaw, &pitch);
snprintf(shot, sizeof(shot), "%s/../shots/frame_%03d_xr.png", argv[2], shotIndex);
@@ -204,7 +245,7 @@ int main(int argc, char** argv) {
if (xrsim) xrsim(shot, steps > 0 ? steps : 1, yaw, pitch);
}
if (envSet("PETARI_DUMP")) {
void (*dump)(const char*) = (void (*)(const char*))dlsym(lib, "port_headless_dump");
void (*dump)(const char*) = (void (*)(const char*))GAME_SYM(port_headless_dump);
snprintf(shot, sizeof(shot), "%s/../shots/frame_%03d", argv[2], shotIndex);
if (dump) dump(shot);
}
@@ -212,7 +253,7 @@ int main(int argc, char** argv) {
}
}
if (envSet("PETARI_THREADS")) {
void (*dumpThreads)(void) = (void (*)(void))dlsym(lib, "port_debug_dump_threads");
void (*dumpThreads)(void) = (void (*)(void))GAME_SYM(port_debug_dump_threads);
if (dumpThreads) dumpThreads();
}
fprintf(stderr, "headless: %d s elapsed, exiting\n", seconds);
+39
View File
@@ -12,6 +12,21 @@
// Declare the C library's printf family (including bionic's FORTIFY inline
// wrappers) before the renames below, so the macros only affect game code.
#include <stdio.h>
// Likewise its wide-string functions: glibc declares them noexcept, and
// libstdc++'s <cwchar> names each one. libc++ also brings <float.h> along.
#include <float.h>
#include <wchar.h>
#ifdef __cplusplus
#include <cwchar>
// libstdc++'s <cstdio> #undefs the printf family: included before the
// renames below, the game's later includes of it leave them in force.
#include <cstdio>
#endif
#ifndef __ANDROID__
// MSL's name for va_list, used by JSystem's headers; glibc does not declare it.
typedef va_list __va_list;
#endif
#ifdef __cplusplus
extern "C" {
@@ -41,6 +56,21 @@ int port_vswprintf(wchar_t* dst, size_t n, const wchar_t* fmt, va_list args);
#define wcscat port_wcscat
#define swprintf port_swprintf
#define vswprintf port_vswprintf
#ifdef __cplusplus
// For std:: spellings after the renames (libstdc++ before GCC 14 takes
// &std::vswprintf in std::to_wstring).
namespace std {
using ::port_swprintf;
using ::port_vswprintf;
using ::port_wcscat;
using ::port_wcschr;
using ::port_wcscmp;
using ::port_wcscpy;
using ::port_wcslen;
using ::port_wcsncmp;
using ::port_wcsncpy;
} // namespace std
#endif
// CodeWarrior MSL printf semantics for game code (NULL "%s" prints nothing,
// "l" is 32-bit, "%ls" is 16-bit); see platform/src/port/msl_printf.cpp.
@@ -53,6 +83,15 @@ int port_sprintf(char* dst, const char* fmt, ...);
#define snprintf port_snprintf
#define vsprintf port_vsprintf
#define sprintf port_sprintf
#ifdef __cplusplus
// libstdc++'s <string> takes &std::vsnprintf (std::to_string).
namespace std {
using ::port_snprintf;
using ::port_sprintf;
using ::port_vsnprintf;
using ::port_vsprintf;
} // namespace std
#endif
#endif
// Renderer hooks called from game code (platform/src/gx/gx_recorder.cpp):
+17 -1
View File
@@ -1,12 +1,19 @@
// Non-standard helpers and pre-C++17 functional adaptors that the game code
// expects from Metrowerks' MSL C++ library. libc++ in C++20 mode no longer
// declares the adaptors, so defining them here does not collide with it.
// declares the adaptors, so defining them here does not collide with it;
// libstdc++ (Linux) still has them, deprecated, and they are taken from it,
// all but MSL's own: mem_func, and binder2nd, which also takes the bound
// value's type and is renamed out of libstdc++'s way.
#pragma once
#include <algorithm>
#include <functional>
#include <iterator>
#ifndef _LIBCPP_VERSION
#define binder2nd msl_binder2nd
#endif
namespace std {
// ---- MSL extensions -------------------------------------------------
@@ -33,6 +40,7 @@ namespace std {
return first;
}
#ifdef _LIBCPP_VERSION
// ---- C++98 adaptor base classes ---------------------------------------
template < class Arg, class Result >
struct unary_function {
@@ -65,6 +73,8 @@ namespace std {
return binder1st< Func >(f, typename Func::first_argument_type(v));
}
#endif // _LIBCPP_VERSION
template < class Func, class Type = typename Func::second_argument_type >
class binder2nd : public unary_function< typename Func::first_argument_type, typename Func::result_type > {
public:
@@ -77,6 +87,7 @@ namespace std {
Type value;
};
#ifdef _LIBCPP_VERSION
template < class Func, class Type >
inline binder2nd< Func, typename Func::second_argument_type > bind2nd(const Func& f, const Type& v) {
return binder2nd< Func, typename Func::second_argument_type >(f, typename Func::second_argument_type(v));
@@ -157,6 +168,8 @@ namespace std {
template < class Result, class Type, class Arg >
inline mem_fun1_ref_t< Result, Type, Arg > mem_fun_ref(Result (Type::*p)(Arg)) { return mem_fun1_ref_t< Result, Type, Arg >(p); }
#endif // _LIBCPP_VERSION
// MSL spelling of mem_fun.
template < class Result, class Type >
inline mem_fun_t< Result, Type > mem_func(Result (Type::*p)()) { return mem_fun_t< Result, Type >(p); }
@@ -167,6 +180,7 @@ namespace std {
template < class Result, class Type, class Arg >
inline const_mem_fun1_t< Result, Type, Arg > mem_func(Result (Type::*p)(Arg) const) { return const_mem_fun1_t< Result, Type, Arg >(p); }
#ifdef _LIBCPP_VERSION
// ---- negators / function pointers ------------------------------------------
template < class Predicate >
class unary_negate : public unary_function< typename Predicate::argument_type, bool > {
@@ -213,4 +227,6 @@ namespace std {
return pointer_to_binary_function< Arg1, Arg2, Result >(p);
}
#endif // _LIBCPP_VERSION
} // namespace std
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Installs GalaxyQuest on the Steam Frame from the player's own Super Mario
# Galaxy disc: extracts the disc image with Dolphin's dolphin-tool (the
# Flatpak from Flathub, installed if missing), converts the game files with
# converter/tools/cook/cook.py into ~/.local/share/GalaxyQuest/game, and adds
# the program to Steam. Run it on the Frame from the unpacked release folder,
# in the desktop mode's Konsole or over SSH:
# ./install.sh <disc image: .iso .rvz .wbfs .gcz .wia .ciso, or an extracted disc folder> [--no-movies]
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
DATA=${GALAXYQUEST_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/GalaxyQuest}
DOLPHIN=org.DolphinEmu.dolphin-emu
# Messages in German on a German system, else in English.
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in de*) DE=1 ;; *) DE=0 ;; esac
t() { if [ $DE = 1 ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
say() { printf '\n== %s\n' "$*"; }
fail() { printf '\n%s: %s\n' "$(t FEHLER ERROR)" "$*" >&2; exit 1; }
SOURCE=""
MOVIES=--with-movies
for arg in "$@"; do
case "$arg" in
--no-movies) MOVIES="" ;;
-h|--help) sed -n '2,8p' "$0"; exit 0 ;;
*) [ -z "$SOURCE" ] || fail "$(t 'nur ein Disc-Abbild angeben' 'give only one disc image')"; SOURCE=$arg ;;
esac
done
[ -n "$SOURCE" ] || fail "$(t 'Pfad zum Disc-Abbild fehlt. Beispiel' 'the path to the disc image is missing. Example'): ./install.sh ~/Downloads/SuperMarioGalaxy.iso"
[ -e "$SOURCE" ] || fail "$(t 'nicht gefunden' 'not found'): $SOURCE"
SOURCE=$(realpath "$SOURCE")
command -v python3 >/dev/null || fail "$(t 'python3 fehlt' 'python3 is missing')"
[ -f "$HERE/converter/tools/cook/cook.py" ] || fail "$(t 'converter/ fehlt neben install.sh: das ganze Release-Archiv entpacken' 'converter/ is missing next to install.sh: extract the whole release archive')"
# The converted game is 3.3 GB (1 GB without movies); the extracted disc up
# to 4.7 GB more while the conversion runs.
mkdir -p "$DATA"
free_gb=$(( $(df -Pk "$DATA" | awk 'NR==2 {print $4}') / 1024 / 1024 ))
[ "$free_gb" -ge 9 ] || fail "$(t "zu wenig Platz in $DATA: $free_gb GB frei, 9 GB nötig" "not enough space in $DATA: $free_gb GB free, 9 GB needed")"
WORK=$DATA/.install
rm -rf "$WORK"
mkdir -p "$WORK"
trap 'rm -rf "$WORK"' EXIT
if [ -d "$SOURCE" ]; then
DISC=$SOURCE
else
DOLPHIN_TOOL=()
if command -v dolphin-tool >/dev/null; then
DOLPHIN_TOOL=(dolphin-tool)
else
command -v flatpak >/dev/null || fail "$(t 'weder dolphin-tool noch flatpak gefunden' 'found neither dolphin-tool nor flatpak')"
if ! flatpak info "$DOLPHIN" >/dev/null 2>&1; then
say "$(t 'Dolphin wird aus Flathub installiert (zum Entpacken der Disc)' 'Installing Dolphin from Flathub (to extract the disc)')"
# Into the user's installation: the system one asks for a password over SSH.
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y --noninteractive flathub "$DOLPHIN"
fi
# The Flatpak sandbox sees only what it is given: the image's folder and the work folder.
DOLPHIN_TOOL=(flatpak run --filesystem="$(dirname "$SOURCE")":ro --filesystem="$WORK" --command=dolphin-tool "$DOLPHIN")
fi
say "$(t 'Disc wird entpackt' 'Extracting the disc'): $SOURCE"
"${DOLPHIN_TOOL[@]}" extract -i "$SOURCE" -o "$WORK/disc" --gameonly --quiet
DISC=$WORK/disc
fi
say "$(t 'Spieldateien werden umgewandelt (einige Minuten)' 'Converting the game files (a few minutes)')"
python3 "$HERE/converter/tools/cook/cook.py" "$DISC" "$WORK/cooked" $MOVIES
[ -f "$WORK/cooked/sys/main.dol" ] || [ -d "$WORK/cooked/files" ] || fail "$(t 'die Umwandlung hat keine Spieldateien erzeugt' 'the conversion made no game files')"
rm -rf "$DATA/game"
mv "$WORK/cooked" "$DATA/game"
chmod 755 "$HERE/galaxyquest"
if command -v steamos-add-to-steam >/dev/null && pgrep -x steam >/dev/null; then
say "$(t 'GalaxyQuest wird zu Steam hinzugefügt' 'Adding GalaxyQuest to Steam')"
steamos-add-to-steam "$HERE/galaxyquest" || true
fi
say "$(t Fertig Done)"
if [ $DE = 1 ]; then
cat <<EOF
Programm: $HERE/galaxyquest
Spieldaten: $DATA/game
In Steam (Desktop-Modus): den neuen Eintrag "galaxyquest" in GalaxyQuest
umbenennen und in den Eigenschaften "In VR-Bibliothek aufnehmen" anhaken.
Fehlt der Eintrag: Spiele -> "Ein Nicht-Steam-Spiel zur Bibliothek
hinzufügen" -> $HERE/galaxyquest
Dann im VR-Modus aus der Bibliothek starten.
EOF
else
cat <<EOF
Program: $HERE/galaxyquest
Game files: $DATA/game
In Steam (desktop mode): rename the new entry "galaxyquest" to GalaxyQuest
and tick "Include in VR Library" in its Properties. If the entry is missing:
Games -> "Add a Non-Steam Game to My Library..." -> $HERE/galaxyquest
Then start it from the library in VR mode.
EOF
fi
+84
View File
@@ -0,0 +1,84 @@
// Linux launcher (Steam Frame): the game code assumes a 32-bit address space,
// so the executable is linked at 0x98000000, the slot libgame.so takes on
// Android, and the rest of the window 0x80000000-0xE0010000 is reserved around
// it before the game maps MEM1/MEM2 there.
//
// galaxyquest the VR app
// galaxyquest --headless <data root> <save root> [s] the bring-up runner
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#ifndef MAP_FIXED_NOREPLACE
#define MAP_FIXED_NOREPLACE 0x100000
#endif
static const uintptr_t kWindowBase = 0x80000000u;
static const size_t kWindowSize = 0x60010000u; // up to 0xE0010000 (locked cache)
static const uintptr_t kImageBase = 0x98000000u;
static const size_t kImageSlot = 0x08000000u; // the executable and the start of its heap
extern char _end[]; // end of the executable's image (lld)
void port_linux_main(uintptr_t windowBase, size_t windowSize);
int port_headless_main(int argc, char** argv);
static void reserve(uintptr_t lo, uintptr_t hi) {
void* p = mmap((void*)lo, hi - lo, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED_NOREPLACE, -1, 0);
if (p == (void*)lo) {
return;
}
fprintf(stderr, "cannot reserve the game address window %p-%p (got %p, errno %d)\n", (void*)lo, (void*)hi, p, errno);
FILE* f = fopen("/proc/self/maps", "r");
char line[512];
while (f && fgets(line, sizeof(line), f)) {
fputs(line, stderr);
}
exit(1);
}
// The kernel starts the brk heap up to 1 GB past a non-PIE executable, which
// can be inside the window. Reserved from .preinit_array, before any
// library's constructor makes the heap, the window wins and malloc uses mmap.
static void reserveWindow(int argc, char** argv, char** envp) {
(void)argc;
(void)argv;
(void)envp;
if ((uintptr_t)_end > kImageBase + kImageSlot) {
fprintf(stderr, "the executable ends at %p, past its slot (%p)\n", (void*)_end, (void*)(kImageBase + kImageSlot));
exit(1);
}
reserve(kWindowBase, kImageBase);
reserve(kImageBase + kImageSlot, kWindowBase + kWindowSize);
}
__attribute__((section(".preinit_array"), used)) static void (*sReserveWindow)(int, char**, char**) = reserveWindow;
int main(int argc, char** argv) {
// The disc layer keeps each game file open once read (2,400 files); a
// shell's soft limit is 1024.
struct rlimit files;
if (getrlimit(RLIMIT_NOFILE, &files) == 0 && files.rlim_cur < files.rlim_max) {
files.rlim_cur = files.rlim_max;
setrlimit(RLIMIT_NOFILE, &files);
}
// SteamOS lets a game raise its priority (RLIMIT_NICE's hard limit is 28
// there: down to nice -8), so the frame loop keeps its refreshes while
// other apps want the same cores (a chat client took two of them). Every
// thread started from here on inherits it.
struct rlimit nice;
if (getrlimit(RLIMIT_NICE, &nice) == 0 && nice.rlim_max > 20) {
nice.rlim_cur = nice.rlim_max;
if (setrlimit(RLIMIT_NICE, &nice) == 0) {
setpriority(PRIO_PROCESS, 0, 20 - (int)(nice.rlim_max > 40 ? 40 : nice.rlim_max));
}
}
if (argc > 1 && strcmp(argv[1], "--headless") == 0) {
return port_headless_main(argc - 1, argv + 1);
}
port_linux_main(kWindowBase, kWindowSize);
return 0;
}
+88 -11
View File
@@ -1,9 +1,16 @@
// Host audio output: the game's AI DMA stream (16-bit stereo, right sample
// first, normally 32 kHz) played through AAudio. Blocks go into a ring that
// AAudio's data callback drains at the device's pace; the AI clock
// (ai_dsp.cpp) keeps the ring topped up. PETARI_WAV=<path> also records the
// stream to a WAV file.
// first, normally 32 kHz) played through AAudio (Android) or ALSA (Linux).
// Blocks go into a ring that the device drains at its own pace (AAudio's data
// callback, or a writer thread blocking in ALSA); the AI clock (ai_dsp.cpp)
// keeps the ring topped up. PETARI_WAV=<path> also records the stream to a
// WAV file.
#ifdef __ANDROID__
#include <aaudio/AAudio.h>
#else
#include <alsa/asoundlib.h>
#include <thread>
#endif
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
@@ -13,13 +20,20 @@
#include <mutex>
#include <vector>
#include "port/heap_routing.h"
#include "port/port.h"
#include "revolution/types.h"
namespace {
std::mutex sLock; // the stream, the resampler and the WAV file
#ifdef __ANDROID__
AAudioStream* sStream = nullptr;
#else
snd_pcm_t* sStream = nullptr;
std::thread sWriter;
std::atomic<bool> sStop{false};
#endif
bool sOpenFailed = false;
std::atomic<bool> sDisconnected{false};
int32_t sDeviceRate = 0;
@@ -138,10 +152,8 @@ void writeWavHeader(FILE* f, u32 frames, u32 rate) {
fseek(f, 0, SEEK_END);
}
// AAudio's real-time thread: copies queued frames out, silence when there
// are none.
aaudio_data_callback_result_t dataCallback(AAudioStream*, void*, void* audioData, int32_t numFrames) {
s16* out = (s16*)audioData;
// The device's thread: copies queued frames out, silence when there are none.
void pullFrames(s16* out, int32_t numFrames) {
u32 r = sRingRead.load(std::memory_order_relaxed);
u32 avail = sRingWrite.load(std::memory_order_acquire) - r;
u32 n = avail < (u32)numFrames ? avail : (u32)numFrames;
@@ -160,6 +172,11 @@ aaudio_data_callback_result_t dataCallback(AAudioStream*, void*, void* audioData
sPlaying = true;
}
sRingRead.store(r + n, std::memory_order_release);
}
#ifdef __ANDROID__
aaudio_data_callback_result_t dataCallback(AAudioStream*, void*, void* audioData, int32_t numFrames) {
pullFrames((s16*)audioData, numFrames);
return AAUDIO_CALLBACK_RESULT_CONTINUE;
}
@@ -205,6 +222,65 @@ bool openStream(u32 rate) {
return true;
}
void closeStream() {
AAudioStream_close(sStream);
sStream = nullptr;
}
#else
const int32_t kDeviceRate = 48000;
const int32_t kPeriodFrames = 240; // 5 ms
const unsigned kLatencyUs = 30000; // ALSA's own buffer, on top of the ring
// Blocks in snd_pcm_writei, so the device paces it as AAudio's callback is
// paced; a device that goes away ends it and port_audio_submit reopens.
void writerLoop(snd_pcm_t* pcm) {
s16 buf[kPeriodFrames * 2];
while (!sStop.load(std::memory_order_relaxed)) {
pullFrames(buf, kPeriodFrames);
snd_pcm_sframes_t n = snd_pcm_writei(pcm, buf, kPeriodFrames);
if (n < 0) {
n = snd_pcm_recover(pcm, (int)n, 1);
}
if (n < 0) {
port_log("audio: ALSA write failed: %s", snd_strerror((int)n));
sDisconnected = true;
return;
}
}
}
bool openStream(u32 rate) {
snd_pcm_t* pcm = nullptr;
int r = snd_pcm_open(&pcm, "default", SND_PCM_STREAM_PLAYBACK, 0);
if (r >= 0) {
r = snd_pcm_set_params(pcm, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED, 2, kDeviceRate, 1, kLatencyUs);
}
if (r < 0) {
port_log("audio: ALSA open failed: %s", snd_strerror(r));
if (pcm) snd_pcm_close(pcm);
return false;
}
sStream = pcm;
sDeviceRate = kDeviceRate;
sRingRead.store(sRingWrite.load());
sResampler = Resampler();
sStop = false;
{
PortHostAllocScope host; // called on a game thread
sWriter = std::thread(writerLoop, pcm);
}
port_log("audio: ALSA %d Hz (source %u Hz), %u ms device buffer", sDeviceRate, rate, kLatencyUs / 1000);
return true;
}
void closeStream() {
sStop = true;
if (sWriter.joinable()) sWriter.join();
snd_pcm_close(sStream);
sStream = nullptr;
}
#endif
} // namespace
extern "C" void port_audio_submit(const s16* rightLeft, u32 frames, u32 rate) {
@@ -237,8 +313,7 @@ extern "C" void port_audio_submit(const s16* rightLeft, u32 frames, u32 rate) {
if (sStream && sDisconnected.exchange(false)) {
port_log("audio: output device disconnected, reopening");
AAudioStream_close(sStream);
sStream = nullptr;
closeStream();
}
if (!sStream && !sOpenFailed) {
sOpenFailed = !openStream(rate);
@@ -283,7 +358,9 @@ extern "C" int port_audio_underruns(void) { return sUnderruns.load(); }
// timer).
extern "C" int64_t port_audio_queued_ns(void) {
std::lock_guard<std::mutex> lock(sLock);
if (!sStream || sDeviceRate <= 0) return sOpenFailed ? -1 : 0;
// A lost device drains nothing: report it empty, so the AI submits the
// next block and port_audio_submit reopens the device.
if (!sStream || sDeviceRate <= 0 || sDisconnected) return sOpenFailed ? -1 : 0;
u32 queued = sRingWrite.load(std::memory_order_relaxed) - sRingRead.load(std::memory_order_acquire);
return (int64_t)queued * 1000000000ll / sDeviceRate;
}
+7 -1
View File
@@ -47,6 +47,9 @@ static u16 sMixerLevel = 0x4000;
extern "C" u32 port_dsp_varam_base(void) { return sVaramBase; }
static void queueMail(u32 a, u32 b) {
// Called on the game's audio thread; the queue's nodes come and go with
// the mail (libstdc++'s deque frees each emptied one): host heap.
PortHostAllocScope scope;
std::lock_guard<std::mutex> lk(sMailMutex);
sMailFromDsp.push_back(a);
sMailFromDsp.push_back(b);
@@ -164,7 +167,10 @@ void DSPSendMailToDSP(u32 mail) {
sWaitingCount = false;
return;
}
sPacket.push_back(mail);
{
PortHostAllocScope scope; // the packet buffer outlives the game's heaps
sPacket.push_back(mail);
}
if (sPacket.size() >= sPacketExpected) {
sWaitingCount = true;
executePacket();
+4
View File
@@ -405,6 +405,10 @@ static void dvdIrq() {
static void submit(DVDCommandBlock* b, u32 entry) {
b->state = kStateBusy;
b->transferredSize = 0;
// The queue's nodes come and go with the requests (libstdc++'s deque
// frees each one the worker has emptied): from the host heap, never the
// game's.
PortHostAllocScope scope;
std::lock_guard<std::mutex> lk(sQueueMutex);
sPending.push_back({b, entry});
sQueueCv.notify_one();
+1
View File
@@ -5,6 +5,7 @@
// big-endian; vertex arrays referenced by index are little-endian (cooked
// model data, or written by the CPU), except single-byte components.
// Texture memory stays big-endian and is decoded here.
#include <math.h>
#include <string.h>
#include <unordered_map>
+15 -2
View File
@@ -26,7 +26,13 @@ bool initContext() {
if (sContext != EGL_NO_CONTEXT) {
return true;
}
#ifdef __ANDROID__
sDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
#else
// Off-screen, as the VR app (xr_app.cpp): over SSH the default platform
// finds no display.
sDisplay = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr);
#endif
if (!eglInitialize(sDisplay, nullptr, nullptr)) {
port_log("headless: eglInitialize failed");
return false;
@@ -58,7 +64,12 @@ bool initContext() {
}
},
nullptr);
gpu::setShaderCachePath(getenv("PETARI_SHADER_CACHE") ? getenv("PETARI_SHADER_CACHE") : "/data/local/tmp/petari/shaders.bin");
#ifdef __ANDROID__
const char* cache = "/data/local/tmp/petari/shaders.bin";
#else
const char* cache = "/tmp/galaxyquest-headless-shaders.bin";
#endif
gpu::setShaderCachePath(getenv("PETARI_SHADER_CACHE") ? getenv("PETARI_SHADER_CACHE") : cache);
return gpu::renderer().init();
}
@@ -384,7 +395,9 @@ extern "C" unsigned long long port_headless_render(const char* path, int scale)
for (int i = 0; i < 4; i++) r.render(tm->t, &ev, nullptr, gpu::HudMode::Skip);
glFinish();
long clk = 0;
if (FILE* g = fopen("/sys/class/kgsl/kgsl-3d0/gpuclk", "r")) {
FILE* g = fopen("/sys/class/kgsl/kgsl-3d0/gpuclk", "r");
if (!g) g = fopen("/sys/class/devfreq/3d00000.gpu/cur_freq", "r"); // Linux (Steam Frame)
if (g) {
if (fscanf(g, "%ld", &clk) != 1) clk = 0;
fclose(g);
}
+58
View File
@@ -0,0 +1,58 @@
// The composite's timewarp (vr::compositeEye): where a pixel of the eye image
// shown now lies in the picture rendered for an earlier pose of the eye.
// tools/timewarp_test checks it.
#pragma once
#include <string.h>
#include "xmath.h"
namespace vr {
// A field of view as the tangents of its angles (left and down negative).
struct FovTan {
float left, right, up, down;
};
// The eye image's uv (0..1, y up) for an eye looking `to` with field of view
// `toFov` -> uv in the picture rendered for `from` with `fromFov`, as a
// homogeneous 3x3 matrix (column-major, for a mat3 uniform; divide by z).
// Rotation only, the scene taken as far away: the eye's millimetres of
// travel in between are not made up for. False, with the identity, when
// the two are the same.
inline bool warpMatrix(const xm::Quat& from, const FovTan& fromFov, const xm::Quat& to, const FovTan& toFov, float out[9]) {
static const float kIdentity[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
memcpy(out, kIdentity, sizeof(kIdentity));
float fdx = fromFov.right - fromFov.left, fdy = fromFov.up - fromFov.down;
bool same = from.x == to.x && from.y == to.y && from.z == to.z && from.w == to.w && fromFov.left == toFov.left &&
fromFov.right == toFov.right && fromFov.up == toFov.up && fromFov.down == toFov.down;
if (same || fdx <= 0.0f || fdy <= 0.0f) {
return false;
}
// uv -> a direction in `to`'s eye space, (x, y, -1).
const float a[3][3] = {{toFov.right - toFov.left, 0.0f, toFov.left}, {0.0f, toFov.up - toFov.down, toFov.down}, {0.0f, 0.0f, -1.0f}};
// `to`'s eye space -> `from`'s.
float q[3][3];
for (int j = 0; j < 3; j++) {
xm::Vec3 v = xm::rotate(xm::conj(from), xm::rotate(to, {j == 0 ? 1.0f : 0.0f, j == 1 ? 1.0f : 0.0f, j == 2 ? 1.0f : 0.0f}));
q[0][j] = v.x;
q[1][j] = v.y;
q[2][j] = v.z;
}
// A direction in `from`'s eye space -> its uv, homogeneous.
const float b[3][3] = {{1.0f / fdx, 0.0f, fromFov.left / fdx}, {0.0f, 1.0f / fdy, fromFov.down / fdy}, {0.0f, 0.0f, -1.0f}};
float qa[3][3];
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
qa[r][c] = q[r][0] * a[0][c] + q[r][1] * a[1][c] + q[r][2] * a[2][c];
}
}
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
out[c * 3 + r] = b[r][0] * qa[0][c] + b[r][1] * qa[1][c] + b[r][2] * qa[2][c];
}
}
return true;
}
} // namespace vr
+193 -70
View File
@@ -17,6 +17,7 @@
#include "../gx/gl_renderer.h"
#include "../gx/gpu.h"
#include "port/port.h"
#include "timewarp.h"
#include "vr_renderer.h"
#include "vr_rig.h"
@@ -29,11 +30,16 @@ const char* kCommon = R"(
vec3 toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c)); }
)";
// uWarp (the composite's timewarp, see timewarp()): the eye image's uv ->
// the diorama's, homogeneous; vSrc is divided out per pixel (srcUv()).
const char* kBlitVs = R"(#version 320 es
uniform highp mat3 uWarp;
out vec2 vUv;
out highp vec3 vSrc;
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
vUv = p;
vSrc = uWarp * vec3(p, 1.0);
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
)";
@@ -49,7 +55,9 @@ uniform float uVignette; // 0 = off .. 1 = strong
uniform vec4 uWipe; // closed (0..1), kind (0 fade, 1 ring), ring centre uv
uniform vec4 uWipeColor; // linear colour, w = width / height of the eye
in vec2 vUv;
in highp vec3 vSrc;
out vec4 oColor;
highp vec2 srcUv() { return vSrc.xy / vSrc.z; } // where the pixel is in the diorama
%s
%s
void main() {
@@ -63,7 +71,7 @@ void main() {
// A ring closing on its centre (radius in units of the eye's height;
// 1.6 clears the view from any centre on it).
float r = (1.0 - uWipe.x) * 1.6;
float inside = 1.0 - smoothstep(r - 0.015, r, length((vUv - uWipe.zw) * vec2(uWipeColor.w, 1.0)));
float inside = 1.0 - smoothstep(r - 0.015, r, length((srcUv() - uWipe.zw) * vec2(uWipeColor.w, 1.0)));
c = mix(uWipeColor.rgb, c, inside);
}
oColor = vec4(c * uBrightness, 1.0);
@@ -71,7 +79,7 @@ void main() {
)";
const char* kBlitPlain = R"(
vec3 sourceColor() { return texture(uTex, vUv).rgb; }
vec3 sourceColor() { return texture(uTex, srcUv()).rgb; }
)";
// AMD FidelityFX CAS (contrast adaptive sharpening), after CasFilter in
@@ -125,15 +133,30 @@ vec3 sourceColor() {
}
)";
// The timewarp's sharpening, once a game frame (renderEyeScene): kBlitCas1
// into an image of the diorama's size, still sRGB-encoded, which every
// refresh's composite then only turns (kBlitPlain). Its %s are the common
// code and kBlitCas1.
const char* kSharpenFs = R"(#version 320 es
precision mediump float;
uniform sampler2D uTex;
in vec2 vUv;
in highp vec3 vSrc;
out vec4 oColor;
%s
%s
void main() { oColor = vec4(sourceColor(), 1.0); }
)";
// The scaled variant: the sharpened results at the four diorama pixels
// around the eye pixel, blended bilinearly with less weight where the local
// contrast is high (keeps edges thin).
// contrast is high (keeps edges thin). Also the one for a turned picture
// (the timewarp).
const char* kBlitCasScaled = R"(
uniform float uCasPeak;
uniform highp vec2 uCasScale;
vec3 casLoad(ivec2 p) { return texelFetch(uTex, clamp(p, ivec2(0), textureSize(uTex, 0) - 1), 0).rgb; }
vec3 sourceColor() {
highp vec2 pp = gl_FragCoord.xy * uCasScale - 0.5;
highp vec2 pp = srcUv() * vec2(textureSize(uTex, 0)) - 0.5;
highp vec2 fp = floor(pp);
pp -= fp;
ivec2 sp = ivec2(fp);
@@ -333,10 +356,12 @@ GLint sLayerTex, sLayerMvp, sLayerUvScale;
// Composite programs: plain, CAS at 1:1, CAS scaling.
struct BlitProgram {
GLuint program = 0;
GLint tex, brightness, vignette, wipe, wipeColor, casPeak, casScale;
GLint tex, brightness, vignette, wipe, wipeColor, casPeak, warp;
};
enum { kBlitPlainProgram, kBlitCas1Program, kBlitCasScaledProgram };
BlitProgram sBlit[3];
GLuint sSharpenProgram;
GLint sSharpenTex, sSharpenPeak, sSharpenWarp;
GLint sLaserMvp, sLaserCorners, sLaserColor, sLaserShape, sLaserRing;
GLuint sMotionProgram, sMotionQuadProgram, sMotionFbo;
GLint sMotionDepth, sMotionMatrix, sMotionDepthOverride, sMotionMaxDepth, sMotionTag;
@@ -402,7 +427,14 @@ unsigned sSkipsSeen = 0;
// Brightness of what is drawn over the view (panels, laser).
float overlayBrightness() { return (1.0f - sFade) * (1.0f - sWipeDim) * (1.0f - sSkipDim); }
gpu::EfbTarget sEye[2], sHud, sFlat;
// The eyes' views of the game frame: two scene sets (the timewarp shows one
// while the next game frame goes into the other), sEye the current one.
gpu::EfbTarget sEyeSets[2][2], sHud, sFlat;
gpu::EfbTarget* sEye = sEyeSets[0];
int sSceneSet = 0;
vr::EyeInfo sSceneEye[2][2]{}; // [set][eye] the eye each view was rendered for
gpu::EfbTarget sSharpSets[2][2]; // [set][eye] the view sharpened once (the sharpening setting with the timewarp)
bool sSceneSharp[2][2]{}; // [set][eye] sSharpSets holds the view
bool sVrMode = false;
vr::RigState sRig;
vr::RigParams sRigParams;
@@ -417,6 +449,7 @@ bool sHighClocks = true; // ask for Meta's SustainedHigh CPU/GPU levels
bool sSpaceWarp = true; // Application SpaceWarp at 120 Hz
bool sSuperRes = true; // Meta Quest Super Resolution (eye at render size)
bool sSharpen = false; // AMD FidelityFX CAS in the composite
bool sTimewarp = true; // the app turns the eye images to each refresh's pose (where the runtime does not)
float sSharpness = 0.5f; // its strength, 0..1
float sMinScale = 0.8f; // lowest render scale (min_resolution)
bool sGiantScreen = true; // gameplay on the giant virtual screen (giant_screen; the default at first start)
@@ -576,7 +609,7 @@ GLuint compile(GLenum type, const char* fmt, bool withCommon) {
return s;
}
GLuint link(const char* vs, const char* fs) {
GLuint linkProgram(const char* vs, const char* fs) {
GLuint p = glCreateProgram();
glAttachShader(p, compile(GL_VERTEX_SHADER, vs, false));
glAttachShader(p, compile(GL_FRAGMENT_SHADER, fs, true));
@@ -590,14 +623,14 @@ BlitProgram linkBlit(const char* source) {
char fs[8192];
snprintf(fs, sizeof(fs), kBlitFs, "%s", source);
BlitProgram b;
b.program = link(kBlitVs, fs);
b.program = linkProgram(kBlitVs, fs);
b.tex = glGetUniformLocation(b.program, "uTex");
b.brightness = glGetUniformLocation(b.program, "uBrightness");
b.vignette = glGetUniformLocation(b.program, "uVignette");
b.wipe = glGetUniformLocation(b.program, "uWipe");
b.wipeColor = glGetUniformLocation(b.program, "uWipeColor");
b.casPeak = glGetUniformLocation(b.program, "uCasPeak");
b.casScale = glGetUniformLocation(b.program, "uCasScale");
b.warp = glGetUniformLocation(b.program, "uWarp");
return b;
}
@@ -922,6 +955,7 @@ vr::Setting sSettings[] = {
{"super_resolution", 0.0f, 1.0f, 0.0f, FLAG(sSuperRes)},
{"sharpening", 0.0f, 1.0f, 0.0f, FLAG(sSharpen)},
{"sharpening_strength", 0.0f, 1.0f, 0.0f, NUMBER(sSharpness)},
{"timewarp", 0.0f, 1.0f, 0.0f, FLAG(sTimewarp)},
{"giant_screen", 0.0f, 1.0f, 0.0f, FLAG(sGiantScreen)},
{"screen_distance", 1.0f, 20.0f, 0.0f, NUMBER(sScreenDistance)},
{"passthrough", 0.0f, 1.0f, 0.0f, FLAG(sPassthrough)},
@@ -1026,6 +1060,7 @@ void setPassthroughAvailable(bool available) { sPassAvailable = available; }
bool highClocks() { return sHighClocks; }
bool spaceWarp() { return sSpaceWarp; }
bool superResolution() { return sSuperRes; }
bool timewarp() { return sTimewarp; }
bool snapTurn(int dir) {
if (!sVrMode || !sRig.valid || sRigParams.turnWithCamera || dir == 0) return false;
sRig.pendingTurn += (dir > 0 ? 1.0f : -1.0f) * 0.78539816f;
@@ -1454,29 +1489,37 @@ void init() {
sBlit[kBlitPlainProgram] = linkBlit(kBlitPlain);
sBlit[kBlitCas1Program] = linkBlit(kBlitCas1);
sBlit[kBlitCasScaledProgram] = linkBlit(kBlitCasScaled);
sQuadProgram = link(kQuadVs, kQuadFs);
{
char fs[8192];
snprintf(fs, sizeof(fs), kSharpenFs, "%s", kBlitCas1);
sSharpenProgram = linkProgram(kBlitVs, fs);
sSharpenTex = glGetUniformLocation(sSharpenProgram, "uTex");
sSharpenPeak = glGetUniformLocation(sSharpenProgram, "uCasPeak");
sSharpenWarp = glGetUniformLocation(sSharpenProgram, "uWarp");
}
sQuadProgram = linkProgram(kQuadVs, kQuadFs);
sQuadTex = glGetUniformLocation(sQuadProgram, "uTex");
sQuadMvp = glGetUniformLocation(sQuadProgram, "uMvp");
sQuadOpaque = glGetUniformLocation(sQuadProgram, "uOpaque");
sQuadBrightness = glGetUniformLocation(sQuadProgram, "uBrightness");
sQuadAlpha = glGetUniformLocation(sQuadProgram, "uAlpha");
sLayerProgram = link(kQuadVs, kLayerFs);
sLayerProgram = linkProgram(kQuadVs, kLayerFs);
sLayerTex = glGetUniformLocation(sLayerProgram, "uTex");
sLayerMvp = glGetUniformLocation(sLayerProgram, "uMvp");
sLayerUvScale = glGetUniformLocation(sLayerProgram, "uUvScale");
sLaserProgram = link(kLaserVs, kLaserFs);
sLaserProgram = linkProgram(kLaserVs, kLaserFs);
sLaserMvp = glGetUniformLocation(sLaserProgram, "uMvp");
sLaserCorners = glGetUniformLocation(sLaserProgram, "uCorners");
sLaserColor = glGetUniformLocation(sLaserProgram, "uColor");
sLaserShape = glGetUniformLocation(sLaserProgram, "uShape");
sLaserRing = glGetUniformLocation(sLaserProgram, "uRing");
sMotionProgram = link(kBlitVs, kMotionFs);
sMotionProgram = linkProgram(kBlitVs, kMotionFs);
sMotionDepth = glGetUniformLocation(sMotionProgram, "uDepth");
sMotionMatrix = glGetUniformLocation(sMotionProgram, "uCurToPrev");
sMotionDepthOverride = glGetUniformLocation(sMotionProgram, "uDepthOverride");
sMotionMaxDepth = glGetUniformLocation(sMotionProgram, "uMaxDepth");
sMotionTag = glGetUniformLocation(sMotionProgram, "uTag");
sMotionQuadProgram = link(kQuadVs, kMotionQuadFs);
sMotionQuadProgram = linkProgram(kQuadVs, kMotionQuadFs);
sMotionQuadTex = glGetUniformLocation(sMotionQuadProgram, "uTex");
sMotionQuadMvp = glGetUniformLocation(sMotionQuadProgram, "uMvp");
sMotionQuadAlphaMin = glGetUniformLocation(sMotionQuadProgram, "uAlphaMin");
@@ -1628,24 +1671,15 @@ void beginFrame(const FrameInfo& frame) {
sRenderNs = port_host_time_ns() - frameStartNs;
}
Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int height) {
int64_t eyeStartNs = port_host_time_ns();
sWipeDim = 0.0f;
gpu::Renderer& r = gpu::renderer();
beginEyeTimer(r.hasFrame() && sVrMode);
const EyeInfo& ei = frame.eyes[eye];
xm::Mat4 viewProj = ei.proj * ei.view;
Extent used{width, height};
namespace {
// The eye's view of the game frame, into the current scene set.
void eyeScene(int eye, const FrameInfo& frame) {
gpu::Renderer& r = gpu::renderer();
const EyeInfo& ei = frame.eyes[eye];
sSceneEye[sSceneSet][eye] = ei;
sSceneSharp[sSceneSet][eye] = false;
if (r.hasFrame() && sVrMode) {
// With Super Resolution the diorama goes out at its render size (the
// lower left part of the image) and the compositor does the one
// scaling to the display; bilinear here first only blurred it. Not
// while the settings panel is up: it and the pause menu are drawn
// into the same image, and their text lost a third of its pixels.
if (sSuperRes && (sUiLayers || !settingsShown()) && sEye[eye].width <= width && sEye[eye].height <= height) {
used = {sEye[eye].width, sEye[eye].height};
}
gpu::EyeView ev;
ev.index = eye;
xm::Mat4 eyeFromView = ei.view * sStageFromView;
@@ -1665,7 +1699,89 @@ Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int hei
sHudDraws = r.hudDrawCount();
}
sSnapTaken[eye] = sMotionOn && r.snapshotTaken();
} else if (r.hasFrame() && sStereoFrame) {
// The eye's picture of the stereo pair. The pair goes to the
// screen's layers once both are drawn (the two eyes may be
// rendered a display refresh apart), so they never show
// different game frames.
gpu::EyeView ev;
ev.index = eye;
ev.flatStereo = true;
ev.stereo[0] = eye == 0 ? sStereoShift : -sStereoShift;
ev.stereo[1] = sConvergence;
ev.stereo[2] = kStereoNear / sStereoFar;
ev.stereo[3] = eye == 0 ? sStereoHudShift : -sStereoHudShift;
ev.pointer[0] = ev.stereo[0] * sPointerParallax;
ev.pointer[1] = sPointerOnHud ? 1.0f : 0.0f;
r.render(sFlatPair[eye], &ev, nullptr, gpu::HudMode::Inline);
if (eye == 1) {
sFlatFresh = sFlatRightFresh = true;
sStereoShown = true;
sScaledShown = false;
sPictureW = sFlatPair[0].width;
sPictureH = sFlatPair[0].height;
}
} else if (eye == 0 && r.hasFrame()) {
r.render(sScaledFrame ? sFlatPair[0] : sFlat, nullptr, nullptr, gpu::HudMode::Inline);
sFlatFresh = true;
sStereoShown = false;
sScaledShown = sScaledFrame;
sPictureW = sFlatPair[0].width;
sPictureH = sFlatPair[0].height;
}
}
// The timewarp's sharpening: the eye's view in the current scene set,
// sharpened once into sSharpSets, so each refresh's composite only turns it.
void presharpen(int eye) {
const gpu::EfbTarget& src = sEye[eye];
gpu::EfbTarget& dst = sSharpSets[sSceneSet][eye];
ensureTarget(dst, src.width, src.height);
glBindFramebuffer(GL_FRAMEBUFFER, dst.fbo);
const GLenum color[1] = {GL_COLOR_ATTACHMENT0};
glInvalidateFramebuffer(GL_FRAMEBUFFER, 1, color);
glViewport(0, 0, dst.width, dst.height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
glDisable(GL_BLEND);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
static const float kIdentity[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
glUseProgram(sSharpenProgram);
glUniformMatrix3fv(sSharpenWarp, 1, GL_FALSE, kIdentity);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, src.color);
glBindSampler(0, 0);
glUniform1i(sSharpenTex, 0);
glUniform1f(sSharpenPeak, -1.0f / (8.0f + (5.0f - 8.0f) * sSharpness));
glBindVertexArray(sVao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
sSceneSharp[sSceneSet][eye] = true;
}
// Scene set `set`'s eye `eye` into the eye image `fbo`, seen from
// `display`'s eye, with the overlays over it.
Extent eyeComposite(int eye, int set, const FrameInfo& display, GLuint fbo, int width, int height) {
sWipeDim = 0.0f;
gpu::Renderer& r = gpu::renderer();
const EyeInfo& ei = display.eyes[eye];
xm::Mat4 viewProj = ei.proj * ei.view;
Extent used{width, height};
if (r.hasFrame() && sVrMode) {
bool presharpened = sSceneSharp[set][eye];
const gpu::EfbTarget& src = presharpened ? sSharpSets[set][eye] : sEyeSets[set][eye];
const EyeInfo& ri = sSceneEye[set][eye]; // the eye the view was rendered for
// With Super Resolution the diorama goes out at its render size (the
// lower left part of the image) and the compositor does the one
// scaling to the display; bilinear here first only blurred it. Not
// while the settings panel is up: it and the pause menu are drawn
// into the same image, and their text lost a third of its pixels.
if (sSuperRes && (sUiLayers || !settingsShown()) && src.width <= width && src.height <= height) {
used = {src.width, src.height};
}
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// The composite covers the whole image: no need to load its old
// contents into the tiles.
@@ -1677,22 +1793,26 @@ Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int hei
glDisable(GL_CULL_FACE);
glDisable(GL_BLEND);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
float warp[9];
bool warped = warpMatrix(ri.orientation, {ri.tanLeft, ri.tanRight, ri.tanUp, ri.tanDown}, ei.orientation,
{ei.tanLeft, ei.tanRight, ei.tanUp, ei.tanDown}, warp);
// FidelityFX CAS sharpens the diorama as it goes in (and scales it
// when the sizes differ).
bool sameSize = used.width == sEye[eye].width && used.height == sEye[eye].height;
const BlitProgram& blit = sBlit[!sSharpen ? kBlitPlainProgram : sameSize ? kBlitCas1Program : kBlitCasScaledProgram];
// when the sizes differ, or the picture is turned).
bool sameSize = !warped && used.width == src.width && used.height == src.height;
const BlitProgram& blit = sBlit[!sSharpen || presharpened ? kBlitPlainProgram : sameSize ? kBlitCas1Program : kBlitCasScaledProgram];
glUseProgram(blit.program);
glUniformMatrix3fv(blit.warp, 1, GL_FALSE, warp);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, sEye[eye].color);
glBindTexture(GL_TEXTURE_2D, src.color);
glBindSampler(0, 0);
glUniform1i(blit.tex, 0);
glUniform1f(blit.brightness, (1.0f - sFade) * (1.0f - sSkipDim));
glUniform1f(blit.vignette, sVignette);
if (sSharpen) {
glUniform1f(blit.casPeak, -1.0f / (8.0f + (5.0f - 8.0f) * sSharpness));
glUniform2f(blit.casScale, (float)sEye[eye].width / used.width, (float)sEye[eye].height / used.height);
}
// The game's screen wipe, across the whole view.
// The game's screen wipe, across the whole view (its ring centred in
// the diorama's picture).
int wipeKind;
float wipeClosed;
unsigned int wipeRgb;
@@ -1704,8 +1824,8 @@ Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int hei
const float* p = r.camera().player;
xm::Vec3 inView = {v[0] * p[0] + v[1] * p[1] + v[2] * p[2] + v[3], v[4] * p[0] + v[5] * p[1] + v[6] * p[2] + v[7],
v[8] * p[0] + v[9] * p[1] + v[10] * p[2] + v[11]};
xm::Vec3 e = xm::transformPoint(eyeFromView, inView);
const xm::Mat4& pr = ei.proj;
xm::Vec3 e = xm::transformPoint(ri.view * sStageFromView, inView);
const xm::Mat4& pr = ri.proj;
float clipX = pr.m[0] * e.x + pr.m[4] * e.y + pr.m[8] * e.z + pr.m[12];
float clipY = pr.m[1] * e.x + pr.m[5] * e.y + pr.m[9] * e.z + pr.m[13];
float clipW = pr.m[3] * e.x + pr.m[7] * e.y + pr.m[11] * e.z + pr.m[15];
@@ -1723,36 +1843,6 @@ Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int hei
drawPanel(sHud, viewProj * panelModel(kHudCenter, kHudWidth, (float)sHud.width / sHud.height), false);
}
} else {
if (r.hasFrame() && sStereoFrame) {
// The eye's picture of the stereo pair. The pair goes to the
// screen's layers once both are drawn (the two eyes may be
// rendered a display refresh apart), so they never show
// different game frames.
gpu::EyeView ev;
ev.index = eye;
ev.flatStereo = true;
ev.stereo[0] = eye == 0 ? sStereoShift : -sStereoShift;
ev.stereo[1] = sConvergence;
ev.stereo[2] = kStereoNear / sStereoFar;
ev.stereo[3] = eye == 0 ? sStereoHudShift : -sStereoHudShift;
ev.pointer[0] = ev.stereo[0] * sPointerParallax;
ev.pointer[1] = sPointerOnHud ? 1.0f : 0.0f;
r.render(sFlatPair[eye], &ev, nullptr, gpu::HudMode::Inline);
if (eye == 1) {
sFlatFresh = sFlatRightFresh = true;
sStereoShown = true;
sScaledShown = false;
sPictureW = sFlatPair[0].width;
sPictureH = sFlatPair[0].height;
}
} else if (eye == 0 && r.hasFrame()) {
r.render(sScaledFrame ? sFlatPair[0] : sFlat, nullptr, nullptr, gpu::HudMode::Inline);
sFlatFresh = true;
sStereoShown = false;
sScaledShown = sScaledFrame;
sPictureW = sFlatPair[0].width;
sPictureH = sFlatPair[0].height;
}
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
@@ -1776,9 +1866,19 @@ Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int hei
setupDraw(viewProj);
}
drawLaser(ei, viewProj);
drawSkipIndicator(frame, viewProj);
drawSkipIndicator(display, viewProj);
glBindVertexArray(0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return used;
}
} // namespace
Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int height) {
int64_t eyeStartNs = port_host_time_ns();
beginEyeTimer(gpu::renderer().hasFrame() && sVrMode);
eyeScene(eye, frame);
Extent used = eyeComposite(eye, sSceneSet, frame, fbo, width, height);
endEyeTimer();
sRenderNs += port_host_time_ns() - eyeStartNs;
if (eye == 1) {
@@ -1787,6 +1887,29 @@ Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int hei
return used;
}
void setSceneSet(int set) {
sSceneSet = set;
sEye = sEyeSets[set];
}
void renderEyeScene(int eye, const FrameInfo& frame) {
int64_t eyeStartNs = port_host_time_ns();
beginEyeTimer(gpu::renderer().hasFrame() && sVrMode);
eyeScene(eye, frame);
if (sSharpen && gpu::renderer().hasFrame() && sVrMode) {
presharpen(eye); // once, not in every refresh's composite
}
endEyeTimer();
sRenderNs += port_host_time_ns() - eyeStartNs;
if (eye == 1) {
port_perf_render(sRenderNs, sUploadNs);
}
}
Extent compositeEye(int eye, int set, const FrameInfo& display, GLuint fbo, int width, int height) {
return eyeComposite(eye, set, display, fbo, width, height);
}
void setMotionSize(int width, int height) {
sMotionW = width;
sMotionH = height;
+22
View File
@@ -16,6 +16,7 @@ struct EyeInfo {
xm::Mat4 view; // eye-from-stage
xm::Vec3 position; // eye position in stage space (metres)
xm::Quat orientation;
float tanLeft, tanRight, tanUp, tanDown; // the field of view (tangents of its angles)
int width, height; // swapchain image size (swapchainScale())
};
@@ -95,6 +96,10 @@ void init();
// sharpening 1 to sharpen (and scale) the diorama with AMD
// FidelityFX CAS as it is composited; 0 (default) off
// sharpening_strength CAS strength, 0 .. 1 (default 0.5)
// timewarp 1 (default) to turn the eye images to the head's pose
// of each display refresh in the app where the runtime
// does not (SteamVR at 120 Hz); 0 shows each game frame
// as rendered, two refreshes long
// invert_camera 1 (default) swaps the right stick's left and right
// where they turn the camera: pushing it right turns the
// view to the right (the camera goes round Mario to the
@@ -216,6 +221,23 @@ struct Extent {
};
Extent renderEye(int eye, const FrameInfo& frame, GLuint fbo, int width, int height);
// renderEye in two parts, for the app's own timewarp (SteamVR shows an eye
// image as it was rendered, where Meta's compositor turns it to the head's
// newest pose). The eyes' views of the game frame go into one of two scene
// sets (setSceneSet, before beginFrame, which sizes them): renderEyeScene
// renders eye `eye` into the current set. compositeEye then draws eye `eye`
// of scene set `set` into `fbo` as renderEye would, for the eye `display`
// describes: the picture is turned from the pose it was rendered for to that
// one (rotation only), and the laser and the other overlays are drawn from
// it. So one set can be shown, every display refresh at its own pose, while
// the next game frame is rendered into the other.
void setSceneSet(int set);
void renderEyeScene(int eye, const FrameInfo& frame);
Extent compositeEye(int eye, int set, const FrameInfo& display, GLuint fbo, int width, int height);
// Whether the app turns the eye images to each refresh's pose itself (the
// timewarp setting; on by default).
bool timewarp();
// SpaceWarp (Meta's XR_FB_space_warp). setMotionSize: the size of the
// motion vector and depth images, before the first frame with
// FrameInfo::motion. renderMotion: after renderEye for both eyes of such a
+34 -5
View File
@@ -12,8 +12,10 @@
// from main.dol, show as to convert again.
#include <GLES3/gl32.h>
#include <dirent.h>
#include <limits.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
@@ -112,6 +114,16 @@ bool isDir(const std::string& path) {
return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
}
// The shared storage the search covers: the headset's, or the home folder on Linux.
std::string storageRoot() {
#ifdef __ANDROID__
return "/storage/emulated/0";
#else
const char* home = getenv("HOME");
return home ? home : "/";
#endif
}
void scanDir(const std::string& dir, int depth, bool skipAndroid, std::vector<Found>& out, int64_t deadline) {
bool ready = false, outdated = false, unknown = false;
if (vr::isGameFolder(dir, &ready, &outdated, &unknown)) {
@@ -155,9 +167,26 @@ void search() {
std::vector<Found> found;
int64_t deadline = port_host_time_ns() + 4000000000ll;
scanDir(appDir, 3, false, found, deadline);
#ifndef __ANDROID__
// SD cards and USB sticks: SteamOS mounts them under /run/media.
scanDir("/run/media", 4, false, found, deadline);
#endif
if (access) {
scanDir("/storage/emulated/0", 4, true, found, deadline);
scanDir(storageRoot(), 4, true, found, deadline);
}
// A folder reached twice (SteamOS also links /run/media/<label> to
// the mount) is listed once.
std::vector<Found> unique;
std::vector<std::string> seen;
for (const Found& f : found) {
char real[PATH_MAX];
std::string key = realpath(f.path.c_str(), real) ? real : f.path;
if (std::find(seen.begin(), seen.end(), key) == seen.end()) {
seen.push_back(key);
unique.push_back(f);
}
}
found.swap(unique);
// Ready folders first.
std::stable_sort(found.begin(), found.end(), [](const Found& a, const Found& b) { return a.ready > b.ready; });
port_log("setup: %zu game folders found (all files access %s)", found.size(), access ? "on" : "off");
@@ -181,10 +210,10 @@ void search() {
// shortened from the left.
std::string shownPath(const std::string& path) {
std::string p = path;
const char* roots[] = {"/storage/emulated/0/", "/sdcard/"};
for (const char* r : roots) {
if (p.compare(0, strlen(r), r) == 0) {
p = "Headset storage/" + p.substr(strlen(r));
const std::string roots[] = {"/storage/emulated/0/", "/sdcard/", storageRoot() + "/"};
for (const std::string& r : roots) {
if (p.compare(0, r.size(), r) == 0) {
p = "Headset storage/" + p.substr(r.size());
break;
}
}
+421 -51
View File
@@ -4,15 +4,21 @@
// Quest requirements verified against Meta's docs (2026-09-28): Khronos loader
// initialised through xrInitializeLoaderKHR + XrLoaderInitInfoAndroidKHR,
// XR_KHR_android_create_instance, Touch Plus via XR_META_touch_controller_plus.
// On Linux (Steam Frame) the GLES context reaches the runtime through
// XR_MNDX_egl_enable, which SteamVR offers alongside XR_KHR_opengl_es_enable.
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES3/gl32.h>
#ifdef __ANDROID__
#include <android/log.h>
#include <android_native_app_glue.h>
#include <jni.h>
#endif
#include <unistd.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <memory>
@@ -20,7 +26,11 @@
#include <thread>
#include <vector>
#ifdef __ANDROID__
#define XR_USE_PLATFORM_ANDROID
#else
#define XR_USE_PLATFORM_EGL
#endif
#define XR_USE_GRAPHICS_API_OPENGL_ES
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
@@ -57,7 +67,9 @@ struct Swapchain {
};
struct App {
#ifdef __ANDROID__
android_app* android = nullptr;
#endif
// EGL
EGLDisplay display = EGL_NO_DISPLAY;
@@ -74,7 +86,9 @@ struct App {
// updateAppSpace).
XrSpace appSpace = XR_NULL_HANDLE;
XrSpace localSpace = XR_NULL_HANDLE, viewSpace = XR_NULL_HANDLE;
bool facePlayerDue = false; // a session began, or the player recentred the view: the app's space is set to the head's pose
bool facePlayerDue = false; // a session became visible, or the player recentred the view: the app's space is set to the head's pose
bool facedThisSession = false; // the session has been visible
int64_t synchronizedAt = 0; // when the session last fell back to SYNCHRONIZED (SteamVR: the headset taken off)
XrTime facePlayerAfter = 0; // not before this time (when a recentring takes effect)
// A session's first seconds: the head's pose in LOCAL is watched for a
// jump (the headset finding its place in the room after waking up).
@@ -85,7 +99,7 @@ struct App {
XrVector3f lastHeadPos{};
XrSessionState state = XR_SESSION_STATE_UNKNOWN;
bool sessionRunning = false;
bool focused = false;
bool focused = true; // the first state change pauses the game until the session has focus
bool pausedInGameplay = false; // focus was lost during ordinary gameplay
uint32_t dpad = 0; // D-pad direction held by the right stick
bool dpadTurned = false; // that flick was a snap turn of the diorama, not the D-pad
@@ -93,6 +107,7 @@ struct App {
int lastRumble = 0;
bool hasRefreshRate = false;
bool hasTouchPlus = false;
bool hasFrameController = false; // XR_VALVE_frame_controller_interaction (Steam Frame)
bool hasPerfSettings = false;
bool hasSpaceWarp = false; // XR_FB_space_warp enabled, with its swapchains
bool hasLayerSettings = false; // XR_FB_composition_layer_settings (Super Resolution)
@@ -131,11 +146,19 @@ struct App {
int heldSet = -1; // paired refreshes: set whose left eye image is acquired and rendered, not yet released
uint32_t heldIdx = 0;
XrTime setTime = 0; // display time the game frame picked up is rendered for
// The app's timewarp (vr::timewarp(), where the runtime does not turn an
// image to the head's newest pose: SteamVR): the game frame's eyes go
// into scene sets (vr::renderEyeScene) instead of swapchain images, and
// every refresh composites the set on show into swapchain set 0.
bool appTimewarp = false;
int sceneShown = -1; // scene set composited each refresh
int sceneDone = -1; // scene set finished, on show from the next refresh on
XrPosef setPose[2][2]; // [set][eye] pose and field of view each image was rendered with
XrFovf setFov[2][2];
XrExtent2Di setRect[2][2]; // [set][eye] part of each image rendered (vr::renderEye)
vr::FrameInfo setFrame{}; // the game frame picked up (from its update refresh)
bool paired = false; // the display refreshes at 120 Hz: game frames take two refreshes each
int pairedChange = 0; // frames the period has disagreed with `paired` (SteamVR)
XrTime lastDisplayTime = 0;
int skippedRefreshes = 0; // refreshes the frame loop missed (logged)
int lateRefreshes = 0; // of those, missed while the frame loop's own work ran late
@@ -152,6 +175,8 @@ struct App {
XrActionSet actionSet = XR_NULL_HANDLE;
XrAction moveAction, lookAction, aAction, bAction, xAction, yAction, triggerAction, gripAction, menuAction, stickClickAction,
aimPoseAction, hapticAction;
XrAction dpadAction[4]; // up, down, left, right: the Steam Frame's left D-pad
XrAction longJumpAction; // the Steam Frame's X: Z, then A
XrPath handPath[2];
XrSpace aimSpace[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
@@ -213,7 +238,16 @@ void loadDebugEnv(const std::string& path) {
// EGL
// ---------------------------------------------------------------------------
void initEgl(App& a) {
#ifdef __ANDROID__
a.display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
#else
// Off-screen, whatever the session's window system: the runtime shows the
// images, and Mesa's Wayland platform has no pbuffer configs.
a.display = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr);
if (a.display == EGL_NO_DISPLAY) {
a.display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
}
#endif
eglInitialize(a.display, nullptr, nullptr);
const EGLint cfgAttr[] = {EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 0, EGL_STENCIL_SIZE, 0,
EGL_SAMPLES, 0, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_NONE};
@@ -244,20 +278,33 @@ bool hasExtension(const std::vector<XrExtensionProperties>& exts, const char* na
return false;
}
// Newer than the OpenXR headers the build uses; its profile's paths are
// accepted only with the extension enabled.
const char* const kFrameControllerExtension = "XR_VALVE_frame_controller_interaction";
void initInstance(App& a) {
#ifdef __ANDROID__
PFN_xrInitializeLoaderKHR initLoader = nullptr;
XR_CHECK(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR", (PFN_xrVoidFunction*)&initLoader));
XrLoaderInitInfoAndroidKHR loaderInfo{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loaderInfo.applicationVM = a.android->activity->vm;
loaderInfo.applicationContext = a.android->activity->clazz;
XR_CHECK(initLoader((const XrLoaderInitInfoBaseHeaderKHR*)&loaderInfo));
#endif
uint32_t count = 0;
XR_CHECK(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr));
std::vector<XrExtensionProperties> exts(count, {XR_TYPE_EXTENSION_PROPERTIES});
XR_CHECK(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, exts.data()));
#ifdef __ANDROID__
std::vector<const char*> enable = {XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME};
#else
if (!hasExtension(exts, XR_MNDX_EGL_ENABLE_EXTENSION_NAME)) {
port_fatal("the OpenXR runtime has no %s: GLES needs it on Linux", XR_MNDX_EGL_ENABLE_EXTENSION_NAME);
}
std::vector<const char*> enable = {XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, XR_MNDX_EGL_ENABLE_EXTENSION_NAME};
#endif
a.hasRefreshRate = hasExtension(exts, XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
if (a.hasRefreshRate) {
enable.push_back(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
@@ -266,6 +313,10 @@ void initInstance(App& a) {
if (a.hasTouchPlus) {
enable.push_back(XR_META_TOUCH_CONTROLLER_PLUS_EXTENSION_NAME);
}
a.hasFrameController = hasExtension(exts, kFrameControllerExtension);
if (a.hasFrameController) {
enable.push_back(kFrameControllerExtension);
}
a.hasPerfSettings = hasExtension(exts, XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
if (a.hasPerfSettings) {
enable.push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
@@ -307,12 +358,13 @@ void initInstance(App& a) {
port_log("XR runtime extensions:%s", names.substr(i, 900).c_str());
}
XrInstanceCreateInfo ci{XR_TYPE_INSTANCE_CREATE_INFO};
#ifdef __ANDROID__
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = a.android->activity->vm;
androidInfo.applicationActivity = a.android->activity->clazz;
XrInstanceCreateInfo ci{XR_TYPE_INSTANCE_CREATE_INFO};
ci.next = &androidInfo;
#endif
strcpy(ci.applicationInfo.applicationName, "GalaxyQuest");
ci.applicationInfo.applicationVersion = 1;
strcpy(ci.applicationInfo.engineName, "Petari");
@@ -404,6 +456,36 @@ void requestPerformanceLevels(App& a) {
port_log("performance levels: CPU and GPU sustained high requested (%d, %d)", (int)cpu, (int)gpu);
}
#ifndef __ANDROID__
// SteamVR offers an app only the refresh rate it runs at: it sets the rate
// per app from its own settings. So the app's rate goes in there, as
// SteamVR's per-application video settings would put it (with SteamVR's
// vrcmd), and SteamVR switches to it at once. The entry is Steam's app id
// for a shortcut, else the one SteamVR makes for an OpenXR app started
// outside Steam.
void setSteamVrRefreshRate(float hz) {
static float sAsked = 0.0f;
const char* vrcmd = "/opt/steamvr/bin/linuxarm64/vrcmd";
if (hz == sAsked || access(vrcmd, X_OK) != 0) {
return;
}
sAsked = hz;
const char* id = getenv("SteamAppId");
std::string key = id && *id && strcmp(id, "0") != 0 ? std::string("steam.app.") + id : "system.generated.openxr.galaxyquest.galaxyquest";
char cmd[512];
snprintf(cmd, sizeof(cmd), "LD_PRELOAD= LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 %s --set-settings-float %s.preferredRefreshRate %.0f >/dev/null 2>&1",
vrcmd, key.c_str(), hz);
port_log("display refresh rate: SteamVR sets it per app; asking for %.0f Hz (%s)", hz, key.c_str());
PortHostAllocScope hostAlloc;
std::thread([command = std::string(cmd)] {
PortHostAllocScope scope;
if (system(command.c_str()) != 0) {
port_log("display refresh rate: vrcmd failed");
}
}).detach();
}
#endif
// Asks for the refresh rate in the settings (120 Hz by default: a whole two
// refreshes per 60 Hz game frame), falling back to 72 Hz.
void requestRefreshRate(App& a) {
@@ -416,7 +498,14 @@ void requestRefreshRate(App& a) {
if (XR_SUCCEEDED(a.xrEnumerateDisplayRefreshRatesFB(a.session, 0, &n, nullptr)) && n > 0) {
std::vector<float> rates(n);
a.xrEnumerateDisplayRefreshRatesFB(a.session, n, &n, rates.data());
#ifndef __ANDROID__
// SteamVR lists only the rate it runs at; the Steam Frame's
// others are reached through its settings (setSteamVrRefreshRate).
const float kFrameRates[] = {72.0f, 90.0f, 120.0f, 144.0f};
vr::setRefreshRates(n > 1 ? rates.data() : kFrameRates, n > 1 ? (int)n : 4); // for the settings panel
#else
vr::setRefreshRates(rates.data(), (int)n); // for the settings panel
#endif
for (float r : rates) {
char buf[16];
snprintf(buf, sizeof(buf), " %.0f", r);
@@ -428,9 +517,15 @@ void requestRefreshRate(App& a) {
a.requestedHz = want;
XrResult r = a.xrRequestDisplayRefreshRateFB(a.session, want);
port_log("display refresh rates:%s Hz; requested %.0f Hz: %d", list.c_str(), want, (int)r);
#ifdef __ANDROID__
if (XR_FAILED(r)) {
a.xrRequestDisplayRefreshRateFB(a.session, 72.0f);
}
#else
if (XR_FAILED(r)) {
setSteamVrRefreshRate(want);
}
#endif
// The rate now (a change arrives as an event).
if (a.xrGetDisplayRefreshRateFB && XR_SUCCEEDED(a.xrGetDisplayRefreshRateFB(a.session, &a.displayHz))) {
port_log("display refresh rate now %.0f Hz", a.displayHz);
@@ -499,6 +594,15 @@ XrPath path(App& a, const char* s) {
return p;
}
void suggestBindings(App& a, const char* profile, const std::vector<XrActionSuggestedBinding>& b) {
XrInteractionProfileSuggestedBinding s{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
s.interactionProfile = path(a, profile);
s.suggestedBindings = b.data();
s.countSuggestedBindings = (uint32_t)b.size();
XrResult r = xrSuggestInteractionProfileBindings(a.instance, &s);
port_log("bindings for %s: %d", profile, (int)r);
}
void suggest(App& a, const char* profile, bool touchPlus) {
std::vector<XrActionSuggestedBinding> b = {
{a.moveAction, path(a, "/user/hand/left/input/thumbstick")},
@@ -520,12 +624,41 @@ void suggest(App& a, const char* profile, bool touchPlus) {
{a.hapticAction, path(a, "/user/hand/right/output/haptic")},
};
(void)touchPlus;
XrInteractionProfileSuggestedBinding s{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
s.interactionProfile = path(a, profile);
s.suggestedBindings = b.data();
s.countSuggestedBindings = (uint32_t)b.size();
XrResult r = xrSuggestInteractionProfileBindings(a.instance, &s);
port_log("bindings for %s: %d", profile, (int)r);
suggestBindings(a, profile, b);
}
// The Steam Frame's controllers: A, B, X and Y all sit on the right one, the
// left has a D-pad and View. B and Y spin, X is the long jump, View alone
// opens the pause menu (+; the right one's Menu does nothing), and the D-pad
// works as the right stick does (updateInput).
void suggestFrame(App& a) {
std::vector<XrActionSuggestedBinding> b = {
{a.moveAction, path(a, "/user/hand/left/input/thumbstick")},
{a.lookAction, path(a, "/user/hand/right/input/thumbstick")},
{a.aAction, path(a, "/user/hand/right/input/a/click")},
{a.bAction, path(a, "/user/hand/right/input/b/click")},
{a.longJumpAction, path(a, "/user/hand/right/input/x/click")},
{a.yAction, path(a, "/user/hand/right/input/y/click")},
{a.triggerAction, path(a, "/user/hand/left/input/trigger/value")},
{a.triggerAction, path(a, "/user/hand/right/input/trigger/value")},
{a.gripAction, path(a, "/user/hand/left/input/squeeze/value")},
{a.gripAction, path(a, "/user/hand/right/input/squeeze/value")},
{a.menuAction, path(a, "/user/hand/left/input/view/click")},
{a.stickClickAction, path(a, "/user/hand/left/input/thumbstick/click")},
{a.stickClickAction, path(a, "/user/hand/right/input/thumbstick/click")},
{a.dpadAction[0], path(a, "/user/hand/left/input/dpad_up/click")},
{a.dpadAction[1], path(a, "/user/hand/left/input/dpad_down/click")},
{a.dpadAction[2], path(a, "/user/hand/left/input/dpad_left/click")},
{a.dpadAction[3], path(a, "/user/hand/left/input/dpad_right/click")},
{a.aimPoseAction, path(a, "/user/hand/left/input/aim/pose")},
{a.aimPoseAction, path(a, "/user/hand/right/input/aim/pose")},
{a.hapticAction, path(a, "/user/hand/left/output/haptic")},
{a.hapticAction, path(a, "/user/hand/right/output/haptic")},
};
// Valve's documented name, and the older one SteamVR before 2.15.1 knows
// (current versions take both).
suggestBindings(a, "/interaction_profiles/valve/frame_controller_valve", b);
suggestBindings(a, "/interaction_profiles/valve/frame_controller", b);
}
void initActions(App& a) {
@@ -547,15 +680,28 @@ void initActions(App& a) {
a.stickClickAction = makeAction(a, "stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, true);
a.aimPoseAction = makeAction(a, "aim_pose", XR_ACTION_TYPE_POSE_INPUT, true);
a.hapticAction = makeAction(a, "haptic", XR_ACTION_TYPE_VIBRATION_OUTPUT, true);
a.dpadAction[0] = makeAction(a, "dpad_up", XR_ACTION_TYPE_BOOLEAN_INPUT, false);
a.dpadAction[1] = makeAction(a, "dpad_down", XR_ACTION_TYPE_BOOLEAN_INPUT, false);
a.dpadAction[2] = makeAction(a, "dpad_left", XR_ACTION_TYPE_BOOLEAN_INPUT, false);
a.dpadAction[3] = makeAction(a, "dpad_right", XR_ACTION_TYPE_BOOLEAN_INPUT, false);
a.longJumpAction = makeAction(a, "long_jump", XR_ACTION_TYPE_BOOLEAN_INPUT, false);
if (a.hasTouchPlus) {
suggest(a, "/interaction_profiles/meta/touch_controller_plus", true);
}
suggest(a, "/interaction_profiles/oculus/touch_controller", false);
if (a.hasFrameController) {
suggestFrame(a);
}
}
void initSession(App& a) {
#ifdef __ANDROID__
XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
#else
XrGraphicsBindingEGLMNDX binding{XR_TYPE_GRAPHICS_BINDING_EGL_MNDX};
binding.getProcAddress = (PFN_xrEglGetProcAddressMNDX)eglGetProcAddress;
#endif
binding.display = a.display;
binding.config = a.config;
binding.context = a.context;
@@ -838,6 +984,18 @@ void updateInput(App& a, XrTime time) {
// B or shake.
if (getBool(a, a.menuAction)) pad.buttons |= W_PLUS;
if (getBool(a, a.xAction)) pad.buttons |= W_MINUS;
// The long jump in one button: Z, and A once Z has held through a game
// frame (Mario::tryJump takes a jump with Z held while running as the
// long jump; standing, it is the backflip).
static int64_t longJumpAt = 0;
if (getBool(a, a.longJumpAction)) {
int64_t now = port_host_time_ns();
if (longJumpAt == 0) longJumpAt = now;
pad.buttons |= W_Z;
if (now - longJumpAt > 25000000) pad.buttons |= W_A;
} else {
longJumpAt = 0;
}
if (getBool(a, a.stickClickAction, a.handPath[1])) pad.buttons |= W_UP;
// Right stick: the D-pad. In the diorama left/right turn it in steps
// round Mario (a snap turn behind a blink; its yaw no longer follows the
@@ -847,6 +1005,12 @@ void updateInput(App& a, XrTime time) {
// right turns the view to the right. Up is the first-person view. A
// direction engages past 0.7 and releases below 0.4, one press per flick.
XrVector2f look = getVec2(a, a.lookAction);
if (a.hasFrameController) { // the Steam Frame's left D-pad, as the stick
if (getBool(a, a.dpadAction[0])) look = {0.0f, 1.0f};
if (getBool(a, a.dpadAction[1])) look = {0.0f, -1.0f};
if (getBool(a, a.dpadAction[2])) look = {-1.0f, 0.0f};
if (getBool(a, a.dpadAction[3])) look = {1.0f, 0.0f};
}
float lx = fabsf(look.x), ly = fabsf(look.y);
if (a.dpad == 0) {
if (lx > 0.7f && lx >= ly) {
@@ -864,9 +1028,15 @@ void updateInput(App& a, XrTime time) {
// Spin: B or Y, or flicking the right controller like a Wii Remote,
// produces the acceleration spike the game reads as a remote shake (some
// objects only answer that one); flicking the left controller shakes the
// Nunchuk, which also spins.
// Nunchuk, which also spins. A flick is the controller moving fast, or a
// quick turn of the wrist (it shakes a Wii Remote as much). Without
// velocities from the runtime the speed comes from where the controller
// was the update before.
static int spinFrames = 0, nunSpinFrames = 0;
static XrTime lastFlick[2] = {0, 0};
static XrVector3f lastPos[2];
static XrTime lastPosTime[2] = {0, 0};
static int flicksLogged = 0;
if (getBool(a, a.bAction) || getBool(a, a.yAction)) {
spinFrames = 6;
}
@@ -874,12 +1044,30 @@ void updateInput(App& a, XrTime time) {
XrSpaceVelocity vel{XR_TYPE_SPACE_VELOCITY};
XrSpaceLocation vloc{XR_TYPE_SPACE_LOCATION};
vloc.next = &vel;
if (XR_SUCCEEDED(xrLocateSpace(a.aimSpace[h], a.appSpace, time, &vloc)) && (vel.velocityFlags & XR_SPACE_VELOCITY_LINEAR_VALID_BIT)) {
if (XR_FAILED(xrLocateSpace(a.aimSpace[h], a.appSpace, time, &vloc)) || !(vloc.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT)) {
lastPosTime[h] = 0;
continue;
}
const XrVector3f& p = vloc.pose.position;
float speed = 0.0f, turn = 0.0f; // m/s, rad/s
if (vel.velocityFlags & XR_SPACE_VELOCITY_LINEAR_VALID_BIT) {
const XrVector3f& v = vel.linearVelocity;
float speed = sqrtf(v.x * v.x + v.y * v.y + v.z * v.z);
if (speed > 2.5f && time - lastFlick[h] > 300000000) { // m/s; 0.3 s between spins
(h == 1 ? spinFrames : nunSpinFrames) = 6;
lastFlick[h] = time;
speed = sqrtf(v.x * v.x + v.y * v.y + v.z * v.z);
} else if (lastPosTime[h] && time > lastPosTime[h]) {
float dx = p.x - lastPos[h].x, dy = p.y - lastPos[h].y, dz = p.z - lastPos[h].z;
speed = sqrtf(dx * dx + dy * dy + dz * dz) / ((time - lastPosTime[h]) * 1e-9f);
}
if (vel.velocityFlags & XR_SPACE_VELOCITY_ANGULAR_VALID_BIT) {
const XrVector3f& w = vel.angularVelocity;
turn = sqrtf(w.x * w.x + w.y * w.y + w.z * w.z);
}
lastPos[h] = p;
lastPosTime[h] = time;
if ((speed > 2.5f || turn > 12.0f) && time - lastFlick[h] > 300000000) { // 0.3 s between spins
(h == 1 ? spinFrames : nunSpinFrames) = 6;
lastFlick[h] = time;
if (flicksLogged++ < 10) {
port_log("vr: %s controller flicked (%.1f m/s, %.0f deg/s): spin", h == 1 ? "right" : "left", speed, turn * 57.3f);
}
}
}
@@ -1049,10 +1237,24 @@ void handleEvents(App& a, bool& quit) {
bi.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
XR_CHECK(xrBeginSession(a.session, &bi));
a.sessionRunning = true;
a.facePlayerDue = true;
a.faceWatchStart = true;
a.facedThisSession = false;
requestRefreshRate(a);
requestPerformanceLevels(a);
} else if (a.state == XR_SESSION_STATE_SYNCHRONIZED) {
a.synchronizedAt = port_host_time_ns();
} else if (a.state == XR_SESSION_STATE_VISIBLE) {
// The view starts where the player faces when they first see
// the session, and again when they put the headset back on:
// SteamVR keeps the session of a headset nobody wears
// SYNCHRONIZED (Meta's runtime stops it). SteamVR's short
// dips through SYNCHRONIZED and the system menu leave the view.
bool backOn = a.synchronizedAt && port_host_time_ns() - a.synchronizedAt > 2000000000ll;
a.synchronizedAt = 0;
if (!a.facedThisSession || backOn) {
a.facePlayerDue = true;
a.faceWatchStart = true;
a.facedThisSession = true;
}
} else if (a.state == XR_SESSION_STATE_STOPPING) {
xrEndSession(a.session);
a.sessionRunning = false;
@@ -1205,6 +1407,10 @@ vr::EyeInfo eyeInfo(const App& a, const XrView& v, int e) {
eye.view = xm::inversePose(q, p);
eye.position = p;
eye.orientation = q;
eye.tanLeft = tanf(v.fov.angleLeft);
eye.tanRight = tanf(v.fov.angleRight);
eye.tanUp = tanf(v.fov.angleUp);
eye.tanDown = tanf(v.fov.angleDown);
eye.width = a.eyes[0][e].width;
eye.height = a.eyes[0][e].height;
return eye;
@@ -1365,6 +1571,30 @@ void renderPair(App& a, int s, const vr::FrameInfo& frame, const XrView views[2]
}
}
// The app's timewarp: both eyes of the scene set on show into swapchain set
// 0, turned to the head's pose at `time` (this refresh's display time).
void compositeShown(App& a, XrTime time) {
XrView views[2];
locateViews(a, time, views);
vr::FrameInfo display = frameInfo(a, views, time);
uint32_t idx[2];
for (int e = 0; e < 2; e++) {
idx[e] = acquireImage(a.eyes[0][e]);
}
for (int e = 0; e < 2; e++) {
Swapchain& sc = a.eyes[0][e];
vr::Extent used = vr::compositeEye(e, a.sceneShown, display, sc.fbos[idx[e]], sc.width, sc.height);
a.setRect[0][e] = {used.width, used.height};
a.setPose[0][e] = views[e].pose;
a.setFov[0][e] = views[e].fov;
}
maybeSaveShot(a, 0, idx);
for (int e = 0; e < 2; e++) {
releaseImage(a.eyes[0][e]);
}
a.shownSet = 0;
}
// Frame pacing. The game runs at 60 Hz. At the default 120 Hz display
// refresh ("paired" refreshes), every game frame stays on screen for exactly
// two refreshes. Refreshes alternate between two steps: the first picks up
@@ -1394,6 +1624,28 @@ void renderPair(App& a, int s, const vr::FrameInfo& frame, const XrView views[2]
// refresh_rate setting) both eyes are rendered every refresh from the
// newest game frame, and the game keeps its own 59.94 Hz clock: frames then
// alternate between one and two refreshes, which makes moving things judder.
//
// SteamVR shows an eye image as it was rendered: it does not turn it to the
// head's pose of each refresh as Meta's compositor does, so at 120 Hz every
// game frame stood still for two refreshes while the head turned (the view
// stepped at 60 Hz, a smear on every head movement). There the app does it
// (the timewarp setting): the paired refreshes render the game frame's eyes
// into a scene set instead (vr::renderEyeScene), and every refresh
// composites the set finished before, turned to that refresh's pose
// (compositeShown), into swapchain set 0.
// GPU time of a refresh beside its eye: the two composites, and SteamVR's
// compositor on the same GPU (it reports no counter of its own), about.
const float kTimewarpCompositeMs = 2.0f;
// The timewarp's scene sees this much (radians) beyond each edge of the
// display's view, so a picture turned to a later pose still fills it (the
// head turns 2.3 degrees in two refreshes at 140 degrees a second).
const float kTimewarpMargin = 0.04f;
void widenView(XrView& v) {
v.fov.angleLeft -= kTimewarpMargin;
v.fov.angleRight += kTimewarpMargin;
v.fov.angleUp += kTimewarpMargin;
v.fov.angleDown -= kTimewarpMargin;
}
void renderFrame(App& a) {
XrFrameWaitInfo wi{XR_TYPE_FRAME_WAIT_INFO};
XrFrameState fs{XR_TYPE_FRAME_STATE};
@@ -1422,10 +1674,28 @@ void renderFrame(App& a) {
// loop's instead while SpaceWarp halves it).
XrDuration refresh = warp ? (XrDuration)(1e9 / a.displayHz) : period;
bool paired = !warp && period > 7900000 && period < 8800000; // 120 Hz
if (paired != a.paired) {
port_log("vr: display period %.2f ms: %s", period / 1e6,
paired ? "each game frame spans two refreshes" : warp ? "SpaceWarp" : "game frames follow the display loosely");
#ifndef __ANDROID__
// SteamVR runs a late app at half the refresh for a while (a period of
// 16.67 ms) and back: the pacing changes only once a new period has held
// for 30 frames, not on every late one (each change starts the paired
// refreshes over).
if (paired != a.paired && ++a.pairedChange < 30) {
paired = a.paired;
} else {
a.pairedChange = 0; // agreed, or changes now
}
#endif
#ifdef __ANDROID__
bool timewarp = false; // Meta's compositor does it
#else
bool timewarp = paired && vr::timewarp();
#endif
if (paired != a.paired || timewarp != a.appTimewarp) {
port_log("vr: display period %.2f ms: %s%s", period / 1e6,
paired ? "each game frame spans two refreshes" : warp ? "SpaceWarp" : "game frames follow the display loosely",
timewarp ? ", each refresh turned to the head's pose (timewarp)" : "");
a.paired = paired;
a.appTimewarp = timewarp;
}
if (a.lastDisplayTime != 0 && refresh > 0) {
int64_t steps = (int64_t)llround((double)(fs.predictedDisplayTime - a.lastDisplayTime) / (double)refresh);
@@ -1519,6 +1789,46 @@ void renderFrame(App& a) {
a.shownSet = s;
a.renderSet = s ^ 1; // for the paired refreshes, should SpaceWarp stop
warpFrame = true;
} else if (timewarp) {
if (a.heldSet >= 0) {
dropHeldEye(a); // a half-rendered set from the paired refreshes without it
}
if (!a.renderDue) {
// First step: the scene set finished on the last refresh goes on
// show, and the newest game frame's left eye into the other.
if (a.sceneDone >= 0) {
a.sceneShown = a.sceneDone;
a.sceneDone = -1;
}
a.setTime = fs.predictedDisplayTime + period * 5 / 2;
updateInput(a, a.setTime);
XrView views[2];
locateViews(a, a.setTime, views);
widenView(views[0]);
widenView(views[1]);
a.setFrame = frameInfo(a, views, a.setTime);
a.setFrame.eyeBudgetMs = period / 1e6f - kTimewarpCompositeMs; // one eye per refresh, beside the composites
vr::setSceneSet(a.renderSet);
vr::beginFrame(a.setFrame);
if (gBooted) port_vi_retrace(); // the game starts its next frame now
vr::renderEyeScene(0, a.setFrame);
a.renderDue = true;
} else {
// Second step: the right eye, for the same display time with the
// freshest prediction of it.
XrView views[2];
locateViews(a, a.setTime, views);
widenView(views[1]);
vr::FrameInfo frame = a.setFrame;
frame.eyes[1] = eyeInfo(a, views[1], 1);
vr::renderEyeScene(1, frame);
a.sceneDone = a.renderSet;
a.renderSet ^= 1;
a.renderDue = false;
}
if (a.sceneShown >= 0) {
compositeShown(a, fs.predictedDisplayTime);
}
} else if (paired && !a.renderDue) {
// First step. The set finished on the last refresh goes up now.
if (a.renderedSet >= 0) {
@@ -1576,9 +1886,11 @@ void renderFrame(App& a) {
vr::FrameInfo frame = frameInfo(a, views, fs.predictedDisplayTime);
frame.eyeBudgetMs = period / 2e6f; // both eyes in one refresh
vr::beginFrame(frame);
renderPair(a, a.renderSet, frame, views);
a.shownSet = a.renderSet;
a.renderSet ^= 1;
// Both eyes in one refresh need no second set (each swapchain cycles
// its own images); SteamVR logs every change of an eye's swapchain.
renderPair(a, 0, frame, views);
a.shownSet = 0;
a.renderSet = 1; // for the paired refreshes
}
XrCompositionLayerProjectionView projViews[2] = {{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
@@ -1749,6 +2061,7 @@ void renderFrame(App& a) {
}
}
#ifdef __ANDROID__
// Android's all files access (MANAGE_EXTERNAL_STORAGE), through JNI (the app
// has no Java code): whether the app has it, and opening the settings page
// where the player grants it.
@@ -1773,7 +2086,6 @@ bool hasAllFilesAccess(App& a) {
if (cls) env->DeleteLocalRef(cls);
return granted;
}
void requestAllFilesAccess(App& a) {
JNIEnv* env = jniEnv(a);
if (!env) return;
@@ -1808,12 +2120,19 @@ void requestAllFilesAccess(App& a) {
port_log("setup: all files access settings %s", ok ? "opened" : "could not be opened");
}
#else
// Linux has no storage permission: the setup screen searches the home folder.
bool hasAllFilesAccess(App&) { return true; }
void requestAllFilesAccess(App&) {}
#endif
void bootGame(const std::string& dataRoot) {
port_log("game files: %s", dataRoot.c_str());
gBooted = true;
port_boot(dataRoot.c_str(), gSaveRoot.c_str());
}
#ifdef __ANDROID__
void onAppCmd(android_app* app, int32_t cmd) {
(void)app;
switch (cmd) {
@@ -1831,19 +2150,35 @@ void onAppCmd(android_app* app, int32_t cmd) {
}
}
} // namespace
// Android's events; true when the activity is being destroyed.
bool pollPlatform(App& a) {
int events;
android_poll_source* source;
int timeout = a.sessionRunning ? 0 : 50;
while (ALooper_pollOnce(timeout, nullptr, &events, (void**)&source) >= 0) {
if (source) {
source->process(a.android, source);
}
if (a.android->destroyRequested) {
break;
}
timeout = 0;
}
return a.android->destroyRequested;
}
#else
bool pollPlatform(App& a) {
if (!a.sessionRunning) {
usleep(50000); // the runtime has not started the session yet
}
return false;
}
#endif
extern "C" __attribute__((visibility("default"))) void port_android_main(android_app* app, uintptr_t windowBase, size_t windowSize) {
PortHostAllocScope scope;
App& a = gApp;
a.android = app;
app->onAppCmd = onAppCmd;
port_mem_set_reserved_window(windowBase, windowSize);
// Cooked game data is pushed to the app's external files dir; saves go to
// internal storage.
std::string ext = app->activity->externalDataPath ? app->activity->externalDataPath : "/sdcard/Android/data/com.galaxy.quest/files";
std::string saveRoot = std::string(app->activity->internalDataPath) + "/nand";
// The app after its platform's start: ext holds the game files, settings and
// logs, internal the saves and the shader cache.
void runApp(App& a, const std::string& ext, const std::string& internal) {
std::string saveRoot = internal + "/nand";
mkdir(saveRoot.c_str(), 0770);
port_log_file((ext + "/petari_log.txt").c_str());
@@ -1861,11 +2196,14 @@ extern "C" __attribute__((visibility("default"))) void port_android_main(android
mkdir(gShotDir.c_str(), 0770);
}
vr::loadSettings((ext + "/petari_vr.ini").c_str()); // before the swapchains are sized
#ifndef __ANDROID__
port_log("priority: nice %d (platform/linux/main.c)", getpriority(PRIO_PROCESS, 0));
#endif
initEgl(a);
initInstance(a);
initActions(a);
initSession(a);
gpu::setShaderCachePath((std::string(app->activity->internalDataPath) + "/shaders.bin").c_str());
gpu::setShaderCachePath((internal + "/shaders.bin").c_str());
vr::init();
initUiLayers(a);
initPerfMetrics(a);
@@ -1897,18 +2235,9 @@ extern "C" __attribute__((visibility("default"))) void port_android_main(android
}
bool quit = false;
while (!quit && !app->destroyRequested) {
int events;
android_poll_source* source;
int timeout = a.sessionRunning ? 0 : 50;
while (ALooper_pollOnce(timeout, nullptr, &events, (void**)&source) >= 0) {
if (source) {
source->process(app, source);
}
if (app->destroyRequested) {
break;
}
timeout = 0;
while (!quit) {
if (pollPlatform(a)) {
break;
}
handleEvents(a, quit);
if (!gBooted) {
@@ -1932,5 +2261,46 @@ extern "C" __attribute__((visibility("default"))) void port_android_main(android
}
}
port_log("exiting");
exit(0);
// Not exit(): with glibc the static destructors wait forever on the
// condition variables the parked game threads sleep on.
fflush(nullptr);
_exit(0);
}
} // namespace
#ifdef __ANDROID__
extern "C" __attribute__((visibility("default"))) void port_android_main(android_app* app, uintptr_t windowBase, size_t windowSize) {
PortHostAllocScope scope;
App& a = gApp;
a.android = app;
app->onAppCmd = onAppCmd;
port_mem_set_reserved_window(windowBase, windowSize);
// Cooked game data is pushed to the app's external files dir; saves go to
// internal storage.
std::string ext = app->activity->externalDataPath ? app->activity->externalDataPath : "/sdcard/Android/data/com.galaxy.quest/files";
runApp(a, ext, app->activity->internalDataPath);
}
#else
// Linux: everything lives in $GALAXYQUEST_HOME, else
// $XDG_DATA_HOME/GalaxyQuest (~/.local/share/GalaxyQuest).
extern "C" void port_linux_main(uintptr_t windowBase, size_t windowSize) {
PortHostAllocScope scope;
port_mem_set_reserved_window(windowBase, windowSize);
std::string dir;
const char* own = getenv("GALAXYQUEST_HOME");
if (own && *own) {
dir = own;
} else {
const char* xdg = getenv("XDG_DATA_HOME"); // only an absolute one counts (XDG spec)
const char* home = getenv("HOME");
std::string data = xdg && xdg[0] == '/' ? xdg : std::string(home ? home : ".") + "/.local/share";
dir = data + "/GalaxyQuest";
}
for (size_t i = 1; i <= dir.size(); i++) { // with its parents
if (i == dir.size() || dir[i] == '/') mkdir(dir.substr(0, i).c_str(), 0700);
}
runApp(gApp, dir, dir);
}
#endif
+66 -2
View File
@@ -119,6 +119,8 @@ extern "C" void port_headless_xrsim(const char* path, int frames, float yawDeg,
eye.view = xm::inversePose(q, offset);
const float r = 48.0f * 3.14159265f / 180.0f;
eye.proj = xm::projectionFov(-r, r, r, -r, vr::kNearZ, vr::kFarZ);
eye.tanLeft = eye.tanDown = -tanf(r);
eye.tanRight = eye.tanUp = tanf(r);
eye.width = kW;
eye.height = kH;
}
@@ -175,8 +177,29 @@ extern "C" void port_headless_xrsim(const char* path, int frames, float yawDeg,
vr::setAimLength(onPanel);
}
vr::beginFrame(fi);
for (int e = 0; e < 2; e++) {
sUsed = vr::renderEye(e, fi, sFbo[e], kW, kH);
// PETARI_XRSIM_WARP=<deg>: the app's timewarp, as at 120 Hz on
// SteamVR: the eyes are rendered for the head's pose and composited
// for the head turned that much further (yaw). Compared with a shot
// rendered at that yaw (PETARI_XRHEAD), only the borders and close
// things (the eyes moved a little) should differ.
static const float sWarpDeg = getenv("PETARI_XRSIM_WARP") ? (float)atof(getenv("PETARI_XRSIM_WARP")) : 0.0f;
if (sWarpDeg != 0.0f) {
vr::FrameInfo shown = fi;
xm::Quat turned = mul(axisAngle({0, 1, 0}, sWarpDeg), q);
for (int e = 0; e < 2; e++) {
vr::EyeInfo& eye = shown.eyes[e];
eye.orientation = turned;
eye.position = xm::rotate(turned, {e == 0 ? -halfIpd : halfIpd, 0, 0});
eye.view = xm::inversePose(turned, eye.position);
vr::renderEyeScene(e, fi);
}
for (int e = 0; e < 2; e++) {
sUsed = vr::compositeEye(e, 0, shown, sFbo[e], kW, kH);
}
} else {
for (int e = 0; e < 2; e++) {
sUsed = vr::renderEye(e, fi, sFbo[e], kW, kH);
}
}
if (sLayers) {
for (int k : vr::kUiLayerOrder) {
@@ -212,6 +235,47 @@ extern "C" void port_headless_xrsim(const char* path, int frames, float yawDeg,
port_perf_gpu_wait(port_host_time_ns() - t0);
return;
}
if (getenv("PETARI_XRSIM_WARP")) {
// The timewarp's left eye against the left eye rendered at the turned
// pose, over the middle half of the image (the borders show what the
// first picture did not have): mean difference per channel, and the
// share of pixels more than 24 levels off.
static GLuint sCheckTex = 0, sCheckFbo = 0;
if (!sCheckTex) {
glGenTextures(1, &sCheckTex);
glBindTexture(GL_TEXTURE_2D, sCheckTex);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_SRGB8_ALPHA8, kW, kH);
glGenFramebuffers(1, &sCheckFbo);
glBindFramebuffer(GL_FRAMEBUFFER, sCheckFbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sCheckTex, 0);
}
vr::FrameInfo direct = fi;
xm::Quat turned = mul(axisAngle({0, 1, 0}, (float)atof(getenv("PETARI_XRSIM_WARP"))), q);
direct.eyes[0].orientation = turned;
direct.eyes[0].position = xm::rotate(turned, {-halfIpd, 0, 0});
direct.eyes[0].view = xm::inversePose(turned, direct.eyes[0].position);
vr::renderEye(0, direct, sCheckFbo, kW, kH);
int x0 = kW / 4, y0 = kH / 4, w = kW / 2, h = kH / 2;
std::vector<unsigned char> a((size_t)w * h * 4), b((size_t)w * h * 4);
glBindFramebuffer(GL_FRAMEBUFFER, sFbo[0]);
glReadPixels(x0, y0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, a.data());
glBindFramebuffer(GL_FRAMEBUFFER, sCheckFbo);
glReadPixels(x0, y0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, b.data());
glBindFramebuffer(GL_FRAMEBUFFER, 0);
double sum = 0;
size_t off = 0;
for (size_t i = 0; i < a.size(); i += 4) {
int d = 0;
for (int c = 0; c < 3; c++) {
int v = abs((int)a[i + c] - (int)b[i + c]);
sum += v;
d = v > d ? v : d;
}
off += d > 24;
}
port_log("xrsim: timewarp vs rendered turned: mean difference %.2f, %.1f%% of pixels over 24", sum / (a.size() / 4 * 3),
100.0 * off / (a.size() / 4));
}
int rw = sUsed.width, rh = sUsed.height;
std::vector<unsigned char> px((size_t)rw * 2 * rh * 4), eyePx((size_t)rw * rh * 4);
// PETARI_XRSIM_SMALL: the left eye only, at half size (quick to save,
+30
View File
@@ -17,6 +17,7 @@ import collections
import concurrent.futures
import multiprocessing
import os
import re
import shutil
import struct
import sys
@@ -161,9 +162,38 @@ def cook_rarc(data, stats, replace=None):
return rarc_swap.swap_rarc(data, cook_file)
LANGUAGE_DIR = re.compile(r'^(Eu|Us|Jp|Kr)[A-Z][a-z]+$')
# EuDutch before EuEnglish: fan translations reuse the English folder.
STAND_IN_FIRST = ('UsEnglish', 'EuDutch', 'EuEnglish')
ARCHIVE_MAGICS = (b'RARC', b'U\xaa8-', b'AA_<')
def language_stand_ins(src):
"""The same file in the disc's other language folders. A fan
translation that grew one language's file over its neighbours on the
disc leaves those unreadable (RMGR01, Russian in the English folder: the
French and German strap screens)."""
parts = src.replace('\\', '/').split('/')
for i, part in enumerate(parts):
if LANGUAGE_DIR.match(part):
base = '/'.join(parts[:i])
others = sorted(d for d in os.listdir(base) if LANGUAGE_DIR.match(d) and d != part)
others.sort(key=lambda d: STAND_IN_FIRST.index(d) if d in STAND_IN_FIRST else len(STAND_IN_FIRST))
return ['/'.join([base, d] + parts[i + 1:]) for d in others]
return []
def cook_archive(src, dst, stats, notes):
raw = open(src, 'rb').read()
data = bytearray(yaz0_decompress(raw))
if data[:4] not in ARCHIVE_MAGICS:
for alt in language_stand_ins(src):
if os.path.isfile(alt):
alt_data = bytearray(yaz0_decompress(open(alt, 'rb').read()))
if alt_data[:4] in ARCHIVE_MAGICS:
notes.append('%s: damaged on this disc, %s used instead' % (src, alt))
data = alt_data
break
if data[:4] == b'AA_<':
return cook_blob(src.replace('\\', '/'), data, stats)
if data[:4] != b'RARC':
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Copies the Linux arm64 build to a Steam Frame over SSH, and the converted
# game files too when their folder is given:
# tools/push_frame.sh <user@headset> [cooked folder]
# The app goes to ~/GalaxyQuest, the game files to
# ~/.local/share/GalaxyQuest/game (where the app looks first).
set -e
HOST=${1:?usage: tools/push_frame.sh <user@headset> [cooked folder]}
COOKED=${2:+$(cd "$2" && pwd)} # before the cd below
cd "$(dirname "$0")/.."
BUILD=${BUILD:-build-linux-arm64}
ssh "$HOST" 'mkdir -p ~/GalaxyQuest ~/.local/share/GalaxyQuest/game'
rsync -a --info=progress2 "$BUILD/galaxyquest" "$BUILD/libopenxr_loader.so.1" "$BUILD/libjsoncpp.so.26" "$HOST:GalaxyQuest/"
if [ -n "$COOKED" ]; then
rsync -a --info=progress2 "$COOKED/" "$HOST:.local/share/GalaxyQuest/game/"
fi
echo "done: run ~/GalaxyQuest/galaxyquest on the headset (add it to Steam as a non-Steam game)"
+77
View File
@@ -0,0 +1,77 @@
// Host-side check for the composite's timewarp (platform/src/xr/timewarp.h):
// c++ -O1 -o timewarp_test tools/timewarp_test/timewarp_test.cpp && ./timewarp_test
// For eyes turned against each other, every pixel of the eye image shown is
// sent to the place in the rendered picture where the same direction in the
// room was drawn; the same eye gives the identity.
#include "../../platform/src/xr/timewarp.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
namespace {
xm::Quat axisAngle(xm::Vec3 axis, float deg) {
float h = deg * 0.5f * 3.14159265f / 180.0f;
return {axis.x * sinf(h), axis.y * sinf(h), axis.z * sinf(h), cosf(h)};
}
xm::Quat mul(xm::Quat a, xm::Quat b) {
return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z};
}
// Where the room direction `world` is drawn in the picture of an eye looking
// `q` with field of view `f` (uv, y up); false when it is behind the eye.
bool project(const xm::Quat& q, const vr::FovTan& f, xm::Vec3 world, float* u, float* v) {
xm::Vec3 e = xm::rotate(xm::conj(q), world);
if (e.z >= 0.0f) return false;
*u = (e.x / -e.z - f.left) / (f.right - f.left);
*v = (e.y / -e.z - f.down) / (f.up - f.down);
return true;
}
int failures = 0;
void check(const char* name, const xm::Quat& from, const vr::FovTan& ff, const xm::Quat& to, const vr::FovTan& tf) {
float m[9];
vr::warpMatrix(from, ff, to, tf, m);
float worst = 0.0f;
for (int iy = 0; iy <= 8; iy++) {
for (int ix = 0; ix <= 8; ix++) {
float u = ix / 8.0f, v = iy / 8.0f;
// The room direction of the shown pixel...
xm::Vec3 dir = xm::rotate(to, {tf.left + u * (tf.right - tf.left), tf.down + v * (tf.up - tf.down), -1.0f});
float eu, ev;
if (!project(from, ff, dir, &eu, &ev)) continue;
// ...against the matrix's answer.
float h0 = m[0] * u + m[3] * v + m[6], h1 = m[1] * u + m[4] * v + m[7], h2 = m[2] * u + m[5] * v + m[8];
float err = fmaxf(fabsf(h0 / h2 - eu), fabsf(h1 / h2 - ev));
worst = fmaxf(worst, err);
}
}
bool ok = worst < 1e-4f;
printf("%-34s worst uv error %.2e %s\n", name, worst, ok ? "ok" : "FAILED");
failures += !ok;
}
} // namespace
int main() {
// A Quest 3 / Steam Frame like eye: wider outwards and downwards.
const vr::FovTan fov{tanf(-0.94f), tanf(0.80f), tanf(0.84f), tanf(-0.98f)};
const xm::Quat head = mul(axisAngle({0, 1, 0}, 30.0f), axisAngle({1, 0, 0}, -20.0f));
float m[9];
if (vr::warpMatrix(head, fov, head, fov, m) || m[0] != 1.0f || m[4] != 1.0f || m[8] != 1.0f || m[1] != 0.0f) {
printf("same eye: not the identity FAILED\n");
failures++;
} else {
printf("%-34s identity ok\n", "same eye");
}
check("yaw 2 deg (a refresh at 240 deg/s)", head, fov, mul(axisAngle({0, 1, 0}, 2.0f), head), fov);
check("yaw -4, pitch 3", head, fov, mul(axisAngle({1, 0, 0}, 3.0f), mul(axisAngle({0, 1, 0}, -4.0f), head)), fov);
check("roll 5", head, fov, mul(head, axisAngle({0, 0, 1}, 5.0f)), fov);
vr::FovTan wider{tanf(-1.0f), tanf(0.9f), tanf(0.9f), tanf(-1.0f)};
check("yaw 3, another field of view", head, fov, mul(axisAngle({0, 1, 0}, 3.0f), head), wider);
return failures ? 1 : 0;
}