mirror of
https://github.com/PodeCaradox/GalaxyQuest-SteamFrame.git
synced 2026-10-17 18:02:54 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa5766398b | ||
|
|
93abf02e90 | ||
|
|
c2073d16e1 | ||
|
|
de8c543ff1 | ||
|
|
52f5100705 | ||
|
|
4425370b05 | ||
|
|
aafcec58a0 | ||
|
|
47f12c20dc | ||
|
|
2c52f411ad | ||
|
|
9bd10a2d81 |
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> **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.
|
||||
|
||||

|
||||
|
||||
## What you need
|
||||
|
||||
- A **Meta Quest 2 or 3** (both tested and working; a Quest 3S should work
|
||||
|
||||
+216
@@ -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)".
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
#include "RVLFaceLibInternal.h"
|
||||
#include <revolution/NAND.h>
|
||||
#include <revolution/nand.h>
|
||||
#include <cstdio>
|
||||
|
||||
#define LOADER_HEADER_BUF_1_SIZE 0x100
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
#include "revolution/OS.h"
|
||||
#include "revolution/os.h"
|
||||
#include "revolution/wud/WUDInternal.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Executable
+102
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
Executable
+17
@@ -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)"
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user