Compare commits

...
25 Commits
Author SHA1 Message Date
DieMitchellandClaude Opus 5.5 4a4696de86 Remove foveation diagnostics and prepare v0.2.0 beta
Drop the pass-size logging and SHADPS4_VR_FOVEATION_TEST coarse mode used
to verify the map on the Frame, point the docs at v0.2.0, and record what
was verified on the headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:13:56 +02:00
DieMitchellandClaude Opus 5.5 853a32b0a4 Foveate the per-eye scene pass instead of wide stereo passes
Astro Bot draws its scene one eye at a time (1200x1280 at preset 5), so
the side-by-side gate never matched it and the map only reached other
wide passes. known_title now publishes the scene's eye size; exactly
that pass gets a single-eye map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:48:01 +02:00
DieMitchellandClaude Opus 5.5 5deb3c3edc Log foveation pass sizes and add a coarse-map test mode
Diagnostic for the Frame test: logs each distinct render-pass size once
with whether it can take the map, and SHADPS4_VR_FOVEATION_TEST=1 forces
4x4 everywhere so a driver ignoring the map is obvious.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:40:28 +02:00
DieMitchellandClaude Opus 5.5 d9d845345a Let the foveation map replace the pipeline shading rate
Pipelines had no fragment shading-rate state, so the combiners defaulted
to KEEP and the attached rate map never changed the shading rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:31:16 +02:00
DieMitchell 1933d4bba3 Handle foveation image-view creation result 2026-10-11 15:04:20 +02:00
DieMitchell 14826294a2 Require single-sample support for rate bands 2026-10-11 14:48:52 +02:00
DieMitchell 8c2d54ccaa Include VMA after Vulkan platform setup 2026-10-11 14:45:34 +02:00
DieMitchell 8bac3b565d Include VMA mapping declarations 2026-10-11 14:33:55 +02:00
DieMitchell cbad4abb99 Drop unused upload allocation metadata 2026-10-11 14:33:45 +02:00
DieMitchell f1c075c140 Report active foveation maps 2026-10-11 14:31:06 +02:00
DieMitchell a6a4620e5a Remove stale optional feature-chain access 2026-10-11 14:30:07 +02:00
DieMitchell d2a6c56370 Clarify conditional render presets 2026-10-11 14:22:18 +02:00
DieMitchell 6b28216ade Keep gaze fallback logging to one warning 2026-10-11 14:20:49 +02:00
DieMitchell 8733d52476 Choose the foveation resolution for direct launches 2026-10-11 14:14:00 +02:00
DieMitchell 66740d71ba Use the gaze action subpath and gate layered passes 2026-10-11 14:13:18 +02:00
DieMitchell b164648378 Clarify foveation is branch-only 2026-10-11 14:12:29 +02:00
DieMitchell 48cbb739f5 Keep optional gaze extensions gated 2026-10-11 14:10:41 +02:00
DieMitchell cf1ae09e3d Fix shading-rate capability query 2026-10-11 14:08:13 +02:00
DieMitchell 8fc1593f01 Add opt-in eye-tracked foveation for Frame 2026-10-11 14:04:02 +02:00
DieMitchell 09d2afee7b Remove non-Frame project files 2026-10-10 23:23:50 +02:00
DieMitchell 54b1157e20 Add on-device installer using SteamOS devkit shortcut registration 2026-10-10 23:15:16 +02:00
DieMitchell d821d1e5b8 Clarify headset installation and Steam shortcut setup 2026-10-10 23:05:27 +02:00
DieMitchell 4fe067a1b0 Rewrite documentation for standalone AstroQuest-Frame 2026-10-10 23:02:17 +02:00
DieMitchell ed58f2617d Rename Steam Frame fork to AstroQuest-Frame 2026-10-10 22:57:32 +02:00
DieMitchell 9404a9295d Map Steam Frame D-pad and document right-stick gestures 2026-10-10 22:55:05 +02:00
91 changed files with 1047 additions and 15904 deletions

No files matched your search

+1 -18
View File
@@ -7,29 +7,12 @@
!/.gitmodules
!/.github/
!/README.md
!/README-QUEST-VR.md
!/README-PC-VR.md
!/README-FRAME.md
!/LICENSE
!/THIRD-PARTY-NOTICES.md
!/Play Astro Bot VR.bat
!/Play AstroQuest.bat
!/Run-Test.ps1
!/Run Recovery.bat
!/Run Original Recovery.bat
!/Run Full Refresh Diagnostic.bat
!/quest-host/
!/shadps4-arm64-main/
# The PC play folder: only the launcher and its settings, not the emulator built into it, nor
# its user folder (saves, logs, shader cache).
!/pc-vr/
/pc-vr/*
!/pc-vr/launch.ps1
!/pc-vr/settings.txt
# Scripts yes, toolchains and downloads no (clang, CMake, Ninja, Windows SDK, sysroot, jadx,
# apktool, PkgTool, the OpenXR loader for Android: see README.md, "Building").
# Keep source tools, but not downloaded toolchains and generated output.
!/tools/
/tools/*/
!/tools/probes/
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Play
pause
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Play
pause
+260 -58
View File
@@ -1,80 +1,282 @@
# AstroQuest on Steam Frame
# AstroQuest-Frame setup and build guide
Native ARM64 Linux emulator with embedded FEXCore translating the PS4 game's CPU
instructions, Vulkan rendering and the headset's SteamVR OpenXR runtime.
Supply your own dumped game; no game or PS4 firmware files are included.
US release CUSA12431 versions 1.00 and 1.04 match the existing European build
signatures and use the same guarded timing, resolution and physics fixes.
Native ARM64 Linux build of AstroQuest for Steam Frame, using embedded FEXCore,
Vulkan and the headset's SteamVR OpenXR runtime. For a quick start and the current
validation status, see [README.md](README.md).
## Build
## Requirements
On the Frame, clone this repository with submodules and run:
- Steam Frame with its built-in SteamVR/OpenXR runtime.
- **Steam Linux Runtime 4.0 - Arm64**, installed on the Frame in Steam. The launcher expects it at
`~/.local/share/Steam/steamapps/common/SteamLinuxRuntime_4-arm64/run`.
- SteamVR support for `XR_VALVE_frame_controller_interaction` for the native Frame
buttons. SteamVR/OpenXR 2.18.2 was used on the device during this port.
- Your own extracted game dump. No game files, firmware or keys are included.
The tested launch uses **US CUSA12431**. Its 1.00 and 1.04 executables match the
existing European signatures and use the same guarded timing, resolution and
physics fixes. European CUSA12392 support is inherited and unverified on Frame;
pass its folder explicitly to the launcher. Other builds are unverified.
## Install
Download the ARM64 archive and `SHA256SUMS.txt` from
[the v0.2.0 release](https://github.com/mitch030504/AstroQuest-Frame/releases/tag/steam-frame-v0.2.0).
Put both files in a folder on the Frame, then run these commands **on the Frame**,
using a terminal or SSH as your normal Steam user:
```sh
git clone --recurse-submodules https://github.com/mitch030504/AstroQuest.git
cd AstroQuest
bash tools/build-frame.sh
sha256sum -c SHA256SUMS.txt && \
tar -xzf AstroQuest-SteamFrame-ARM64.tar.gz && \
bash astroquest-frame/install.sh
```
The script builds inside a dedicated rootless Podman container using Valve's ARM64
Steam Runtime 4 SDK, pinned to `4.0.20260805.254769`. Packages are installed inside
that container. Compilation uses two jobs. Output is
`build/AstroQuest-SteamFrame-ARM64.tar.gz`.
For SSH access, see [Valve's developer-mode setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup#developer-mode).
Run the installer without `sudo`.
## Install and run
The installer:
Download `AstroQuest-SteamFrame-ARM64.tar.gz` from
[the release page](https://github.com/mitch030504/AstroQuest/releases/tag/steam-frame-v0.1.0).
Install **Steam Linux Runtime 4.0 ARM64** in Steam before launching.
- Checks the ARM64 package and that Steam is running.
- Opens the installation dialog for Steam Linux Runtime 4.0 - Arm64 if it is
missing. Finish that download in the headset, then rerun the installer.
- Copies the application into `~/Applications/astroquest-frame`.
- Registers **Devkit Game: AstroQuest_Frame** through Steam's devkit interface
and confirms that Steam lists the shortcut.
Extract the archive into `~/Applications`. Put the extracted US game in
`~/Games/AstroQuest/CUSA12431`, with update files in the sibling
`CUSA12431-UPDATE` directory. Both need `sce_sys/param.sfo`.
The installer uses the devkit helpers supplied on the Frame. It creates
`~/devkit-game/AstroQuest_Frame` as a link to the app,
with the shortcut's configuration alongside it. Repeating the installation
updates the same devkit title. Existing saves remain in the separate data directory.
Use the PC launcher’s PKG extractor with its bundled runtime. Do not run the old
PkgTool under a newer .NET runtime using `DOTNET_ROLL_FORWARD`: its single
`DeflateStream.Read` leaves compressed blocks partly filled with zeroes. For a
source build targeting .NET 10, change that call in `PFS/PFSCReader.cs` to
`ds.ReadExactly(output, 0, hdr.BlockSz)` before extracting.
To choose a game folder, pass it to the installer:
```sh
bash astroquest-frame/install.sh /path/to/CUSA12431
```
The folder must contain `eboot.bin` and `sce_sys/param.sfo`. Rerunning without an
argument preserves the saved location; a new install uses the default US path.
Game files are prepared separately, as described below.
## Prepare the game
Put the extracted US base game in `~/Games/AstroQuest/CUSA12431`. If you have the
1.04 update, extract it into the sibling `CUSA12431-UPDATE` directory. Each folder
needs its own `sce_sys/param.sfo`; the update alone cannot replace the base game.
The launcher still points at the base game's `eboot.bin` and the emulator loads
its sibling update.
```text
~/Games/AstroQuest/
├── CUSA12431/
│ ├── eboot.bin
│ ├── sce_sys/param.sfo
│ └── ...remaining base-game files
└── CUSA12431-UPDATE/ # optional
├── sce_sys/param.sfo
└── ...extracted update files
```
The launcher accepts an extracted folder, not a PKG file. To extract a package made
from your own dump, use PkgTool from the
[upstream AstroQuest PC release](https://github.com/evertec82/AstroQuest/releases)
on a Windows PC, then copy the extracted folders to Frame.
### Linux PKG extraction caveat
Running the old PkgTool with `DOTNET_ROLL_FORWARD` under a newer .NET runtime
produced incomplete decompressed blocks during this port: the game could pass the
title screen and then fail while loading the opening scene.
For the LibOrbisPkg source build targeting .NET 10 used to repair that extraction,
the call in `LibOrbisPkg/PFS/PFSCReader.cs` must read the entire output buffer:
```csharp
ds.ReadExactly(output, 0, hdr.BlockSz);
```
Re-extract both the base and update after fixing the extractor. The game's
`param.sfo` is a separate package entry and must also be extracted into `sce_sys`.
PkgTool and game files are not included in the Frame archive.
## Launch in VR
After installation, choose **Library > Non-Steam > Devkit Game: AstroQuest_Frame**
on the headset. Close the Steam dashboard so the OpenXR session receives input.
The launcher uses the Frame's built-in VR runtime, installed Steam Linux Runtime
4 ARM64 and system Vulkan driver.
For a terminal launch from the default installation:
```sh
~/Applications/astroquest-frame/launch.sh
# Or pass the location of your extracted game:
~/Applications/astroquest-frame/launch.sh /path/to/CUSA12431
```
Add `launch.sh` as a non-Steam game for launching from the headset's library.
In the shortcut’s Properties, enable **Include in VR Library**. Close the Steam
dashboard to give the OpenXR session focus.
The launcher invokes the installed Steam Linux Runtime 4 ARM64 and uses the active
OpenXR runtime and system Vulkan driver. SteamVR must be running.
Extra emulator arguments may follow the game folder. Quit and relaunch after
updating so the running game loads the new binary and bindings.
Saves, configuration and shader cache are under
`~/.local/share/astroquest/user`; console output is in
`~/.local/share/astroquest/logs/console.log`. Replacing the application keeps them.
PS4 firmware modules, if needed, go in `user/sys_modules`.
### Alternative: SteamOS Devkit Client
Frame controllers use the native Steam Frame OpenXR profile: left stick moves,
right stick controls the touchpad, and the right controller's A/B/X/Y buttons are
Cross/Circle/Square/Triangle respectively. A jumps and X attacks. Hold B + Y to
blow without a microphone. SteamVR's automatic Touch conversion maps B/X/Y to
the same button, so it must support `XR_VALVE_frame_controller_interaction`.
Triggers and grips provide the existing gadget gestures. Press the left View
button for OPTIONS, or press both sticks together to recenter. The right
controller supplies the controller's tracked pose.
SDL gamepads, including DualSense, remain supported. Audio and microphone use
the system defaults.
While VR is active, the emulator filters out the Frame controllers' duplicate SDL gamepad
(`28de:11e0`) so their buttons and tracked pose come through OpenXR together.
You can deploy from a PC with the
[SteamOS Devkit Client](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames).
Extract the archive **on that PC**. Enable Developer Mode on the Frame, select
**Pair new host**, register the Frame in the client's **Devkits** tab, and confirm
pairing in the headset. Open **Title Upload** and use:
The initial scene resolution is 816×870 per eye, with MSAA capped at one sample
and GPU commands flushed every 64 draws, matching the existing mobile defaults.
Set `SHADPS4_TITLE_RESOLUTION=4`, `5`
or `6` before launching to select 960×1080, 1200×1280 or 1440×1536. The existing
VR settings documented in README-PC-VR.md also apply.
| Field | Value |
| --- | --- |
| Name | `AstroQuest_Frame` |
| Local Folder | The extracted `astroquest-frame` folder on your PC, containing `astroquest` and `launch.sh` |
| Start Command | `./launch.sh` |
| Runtime | `Not specified` — the packaged launcher invokes Runtime 4 ARM64 itself |
## Status
Upload, then use **Start** or launch the resulting devkit game from the headset's
Non-Steam library. Install Runtime 4 ARM64 on the Frame before launching; the
installer above opens its Steam dialog when needed. Devkit title IDs use
underscores here because the client's name validation does not accept hyphens.
A custom game path can follow `./launch.sh` as a quoted argument in Start Command.
Experimental: stereo VR launch, head/controller tracking and opening gameplay were
checked on Steam Frame with SteamVR/OpenXR 2.18.2. Full gameplay, gadgets,
microphone and separate gamepads remain unverified.
## Controls
These are the native Frame bindings in v0.1.1 and later, with the default right controller
supplying the virtual controller's position and orientation:
| Frame input | PS4 input / behavior |
| --- | --- |
| Right A | Cross — jump / confirm |
| Right B | Circle |
| Right X | Square — attack |
| Right Y | Triangle |
| Left D-pad | PS4 Up / Down / Left / Right |
| Left stick | Move |
| Left stick pressed | L3 |
| Right stick moved | Move a finger on the PS4 touchpad |
| Right stick pressed | Touchpad click |
| Left View | Options; hold for about a second to recenter |
| Both sticks pressed together | Recenter |
| Left grip held + right stick flicked sideways | Turn the view, normally 30 degrees per flick |
| B + Y held together | Simulate blowing; the combined press is consumed by this gesture |
| Left / right trigger | L2 / R2, plus the gadget gestures below |
| Left / right grip | L1 / R1, plus the gadget gestures below |
The right stick's touchpad input can appear inactive before a gadget needs it.
Turning with the left-grip gesture temporarily takes over from touchpad movement.
The D-pad sends ordinary PS4 D-pad input; its visible effect depends on the screen.
The existing gadget gestures, with the default controller hand, are:
| Input | Touchpad gesture / intended use |
| --- | --- |
| Right trigger held | Press and hold — water cannon / machine gun |
| Right grip pressed | Swipe forward — hook / throwing stars / chests |
| Left trigger pulled, then released | Pull back and release — catapult |
Gadget behavior and microphone input remain unverified on Frame. Audio and
microphone use the system default devices. Separate SDL gamepads, including
DualSense, retain the existing input path but are also unverified on Frame.
The native profile avoids SteamVR's automatic Touch conversion, which mapped
Frame B, X and Y to Square. During VR, the emulator filters out the Frame
controllers' duplicate SDL gamepad (`28de:11e0`) so OpenXR controls their buttons
and tracked pose together. Restart an older running build to load these bindings.
## Settings
Set environment variables before launching, or use Steam launch options of the
form `NAME=value %command%`. The defaults below refer to `tools/launch-frame.sh`.
| Setting | Behavior |
| --- | --- |
| `SHADPS4_TITLE_RESOLUTION=3` | 816×870 per eye; default when foveation is off |
| `SHADPS4_TITLE_RESOLUTION=4` | 960×1080 per eye |
| `SHADPS4_TITLE_RESOLUTION=5` | 1200×1280 per eye; default when foveation is enabled |
| `SHADPS4_TITLE_RESOLUTION=6` | 1440×1536 per eye |
| `SHADPS4_VR_FOVEATION=1` | Experimental eye-tracked variable-rate shading; defaults to preset 5 unless a resolution is explicitly set |
| `SHADPS4_MAX_MSAA=1` | Default maximum of one MSAA sample |
| `SHADPS4_FLUSH_DRAWS=64` | Default GPU command flush interval |
| `SHADPS4_XR_WAIT=30` | Default wait, in seconds, for a headset |
| `SHADPS4_VR_TURN=30` | Default turn step in degrees; `0` disables stick turning |
For example:
```sh
SHADPS4_TITLE_RESOLUTION=4 ~/Applications/astroquest-frame/launch.sh
```
Enable eye-tracked foveation from the Frame shortcut's launch options with
`SHADPS4_VR_FOVEATION=1 %command%`. It requires OpenXR eye-gaze support and
Vulkan fragment-shading-rate attachments. If either is unavailable, the game
uses full-rate shading and records the fallback in its console log. The gaze
sample is mapped onto the per-eye scene passes; invalid or older than
100 ms samples also use full-rate shading. On the Frame, detail was
seen to follow the player's gaze, and one level held 45–51 frames a second at
1200×1280. It has not been benchmarked against full-rate shading in the same
scene, and does not promise a performance gain.
The inherited pacing code defaults to `SHADPS4_VR_PACE=1`, targeting one rendered
frame per headset refresh. The launcher's `SHADPS4_VR_FPS_CAP=60` default does not
override that fixed pace. To select the governor and apply its FPS cap, use
`SHADPS4_VR_PACE=0` together with `SHADPS4_VR_FPS_CAP=60`. Available frame rates are
quantized to the headset refresh rate; performance still depends on scene load.
## Saves, updates and logs
| Location | Contents |
| --- | --- |
| `~/Applications/astroquest-frame/` | Installed application, launcher and documentation |
| `~/.local/share/astroquest/user/` | Saves, configuration and shader cache |
| `~/.local/share/astroquest/user/sys_modules/` | Your PS4 firmware modules, if needed |
| `~/.local/share/astroquest/logs/console.log` | Appended console output |
| `~/.local/share/astroquest/user/log/shad_log.txt` | Emulator log |
If `XDG_DATA_HOME` is set, the data and logs use `$XDG_DATA_HOME/astroquest`.
Extract a newer archive, run its `install.sh`, and restart the game.
The installer replaces the application and preserves the separate save directory.
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| Installer reports missing Steam | Start Steam and run as the logged-in Steam user, without sudo |
| Launcher prints its usage and exits | Pass an extracted game folder containing `eboot.bin` and `sce_sys/param.sfo` |
| Installer opens a runtime installation dialog | Finish installing Steam Linux Runtime 4.0 - Arm64 in the headset, then rerun `install.sh` |
| Flat window or black headset view | Check that the Frame's built-in VR runtime is running and inspect the console log |
| Controllers do not respond | Close the Steam dashboard and give the game focus |
| B, X and Y all act as Square | Restart with v0.1.1 and check for `Steam Frame native bindings` in the log; the runtime must offer the Frame extension |
| D-pad does not respond | Restart with v0.1.1; check in a screen that uses the D-pad |
| Right stick appears inactive | It supplies touchpad input; try it when a gadget uses the touchpad, or hold the left grip to turn the view |
| Game fails after the title while loading | Check that both game folders were extracted completely; see the Linux PKG caveat above |
When reporting an issue, include the release version, SteamVR version, game serial
and version, the scene where it occurs, and the relevant log excerpt. A full game
playthrough, latest Circle/D-pad gameplay checks, gadgets, microphone and separate
gamepads are still outstanding.
## Build from source
Run on Steam Frame or an ARM64 Linux host with Git and rootless Podman available.
The first build needs network access for the SDK image, dependencies and locked
FEX checkout:
```sh
git clone --recurse-submodules https://github.com/mitch030504/AstroQuest-Frame.git
cd AstroQuest-Frame
bash tools/build-frame.sh
```
The script uses the dedicated Podman container `astroquest-build`, with Valve's
ARM64 Steam Runtime 4 SDK pinned to `4.0.20260805.254769`. Packages are installed
inside that container. Keep the clone at the same path when reusing its container.
The emulator compilation uses two jobs; the FEX build uses its existing script.
The build also runs the FEX smoke/harness checks and checks ARM64 output and
runtime library resolution.
Output is `build/AstroQuest-SteamFrame-ARM64.tar.gz`; its staging directory is
`build/astroquest-frame/`. Install that archive using its `install.sh`, as described above.
The container stays running after the script finishes. To stop it after building:
```sh
podman stop astroquest-build
```
-847
View File
@@ -1,847 +0,0 @@
# Astro Bot Rescue Mission on PC VR
## Valve Index through SteamVR
Use the same Windows PC build and **Play Astro Bot VR.bat** with a Valve Index. You do
not need Virtual Desktop. Headset playback with a PC-connected DualSense was reported
working on 2026-10-05. The Index-controller fallback and Quest/VDXR regression still need
physical headset tests.
What is said here of an Index holds for the other headsets SteamVR drives, and for those
with an OpenXR runtime of their own: players have reported a Bigscreen Beyond (SteamVR) and
a Pimax Dream Air (Pimax's runtime, and SteamVR) working. The launcher uses whatever
runtime is the PC's active one.
1. Start SteamVR with the Index and base stations connected. In **Settings > OpenXR**,
select **Set SteamVR as OpenXR Runtime**. The launcher uses the active runtime without
changing it. An `XR_RUNTIME_JSON` environment variable overrides that selection; remove
a Virtual Desktop or simulator override before launching with SteamVR.
2. Choose the headset refresh rate in SteamVR's Video settings. Full-refresh rendering
is the default: the game targets that rate, subject to scene workload and PC performance.
3. Connect the **DualSense to the PC** by USB or Bluetooth. Buttons, touchpad, gyro,
rumble and light bar use the existing gamepad path. If you add a Steam shortcut, disable
**Steam Input for that shortcut** so Steam does not replace it with a virtual Xbox pad.
4. Select the Index speakers and microphone in SteamVR's Audio settings, or make them
Windows' default output and input devices. The game needs the microphone for blowing.
5. Put your own game dump in `games`, or choose it when the launcher asks. Start
**Play Astro Bot VR.bat**, choose the graphics settings, and press Play. Hold OPTIONS
for a second or press the PS button to recenter after sitting down.
The Index tracks your head in six degrees of freedom, but does not track bare hands or the
DualSense's position. Without a tracked position, the virtual gamepad rests in front of you
and follows your seating position; the DualSense gyro controls its rotation. Moving the
physical gamepad alone does not move its virtual position. The initial controller-alignment
screen uses the existing untracked-gamepad fallback. Set `hands=0` if parked VR controllers
cause an incorrect gamepad position. **You can put that gamepad where you want it**: see
"The gamepad in the game, where nothing tracks it" below.
Without a gamepad connected, Index controllers can use the existing VR-controller fallback:
right A is cross, right B is square, left A is circle, left B is triangle, and pressing the
left trackpad is OPTIONS. Sticks, triggers, grips, rumble and recentering follow the Touch
layout below. The connected DualSense takes priority over VR controllers.
The renderer covers the Index's canted eye views with parallel projections, as required by
the emulated PSVR game. SteamVR handles the final reprojection into the headset's eye views.
The saved headset FOV is reused only when the OpenXR runtime (including its version), headset
name and vendor match. With no confirmed headset identity or matching cache, the title uses
the PSVR default until the next game start; legacy caches without an identity are ignored.
## Quest through Virtual Desktop
## PC performance fork based on AstroQuest 0.20
This fork merges upstream AstroQuest 0.20 (`807ca1f`) with the PC performance changes.
It retains upstream game-version 1.00/1.04 support, headset/controller handling, language
selection, snap turning, microphone gain and button-to-blow controls, audio-device handling,
and the World 2 moving-collision and lava-rendering fixes.
Double-click `Play AstroQuest.bat` to choose maximum per-eye resolution, framerate, field of view
and the upstream launcher options. Choices are saved in `pc-vr/settings.txt`. Full-refresh
pacing is the default (`fps=full`); the FPS selector also offers fixed caps. Set headset refresh in
SteamVR or Virtual Desktop. The resolution list now extends to 3960x4224 and 4320x4608 per
eye; the runtime may downsample the submitted image to its own swapchain limits. The initial
resolution remains 3600x3840. For native-frame comparisons, disable Virtual Desktop SSW
or SteamVR motion smoothing.
- OpenXR uses a second queue in the same Vulkan graphics family when available, with a
producer timeline semaphore and copy-completion fence. `SHADPS4_XR_SHARED_QUEUE=1`
restores shared-queue operation for comparison.
- Normal launch defaults to Full framerate; selecting a numeric FPS cap opts into adaptive
pacing with that cap. `Run Full Refresh Diagnostic.bat` forces full-refresh mode regardless
of the menu selection, bypassing adaptive half-rate pacing. This targets one new frame per refresh, but cannot guarantee
native refresh or smooth motion where a frame exceeds its budget.
- `Run Recovery.bat` opts into adaptive pacing with 15-second full-refresh retries and the
existing GPU headroom checks and failure backoff. `Run Original Recovery.bat` uses the
original 600-second retry gate.
- Frame diagnostics measure Windows CPU thread time and distinguish headset queue contention
from time inside the runtime. Launchers archive settings and logs under `test-logs/`.
- Upstream now provides the SteamVR address-space reservation and runtime-specific idle
recovery behavior; those implementations are retained instead of duplicate fork patches.
### Selected elliotttate performance port (candidate)
This candidate adds pipeline-cache preload repairs and bounded late-frame recovery. See
[the port notes](docs/elliotttate-performance-port.md) for scope, attribution and limitations.
Set `env=SHADPS4_XR_LATE_WAIT_MS=0` in settings.txt to disable late-frame waiting.
### Precise VR presentation pacing (experimental)
VR early-flip presentation now uses a reusable Windows high-resolution waitable timer
(with a standard-timer fallback) and checks finished frames 32 times per refresh instead
of eight. The polling interval follows the runtime's current refresh rate, including
changes after startup. Other emulator timers keep their previous behavior. No global
Windows timer-resolution change or busy wait is introduced.
This targets wakeup jitter and the delay between GPU completion and flip; it cannot make
a game frame that exceeds the headset's frame budget arrive on time. The latest full-rate
90 Hz run often delivered 88-89 frames/s with occasional 19-25 ms gaps. Standalone timer
checks and startup validation do not establish a gameplay improvement: compare the same
scene with the full-refresh diagnostic. To restore the previous presentation behavior,
add `env=SHADPS4_VR_PRECISE_PACING=0` to `pc-vr/settings.txt`.
### Soccer-enemy timing fix (game 1.00)
The fix for the level 2-2 soccer-enemy assertion changes two animation-budget
conversions to use the console's immutable 1/60-second frame unit. The previous calculation
multiplied the budget by the current update step, shrinking it above 60 FPS while the
animation's duration stayed in seconds. Rendering and elapsed-time integration remain
variable-rate. The animation-resource check and assertion are retained.
This patch is verified against the local 1.00 executable's instruction bytes and refuses
unexpected code. It is not applied to 1.04, whose locations have not been verified. Set
`SHADPS4_TITLE_SOCCER_TIMING=0` (or `env=SHADPS4_TITLE_SOCCER_TIMING=0` in settings.txt)
for a comparison without it. A successful patch is reported as `Soccer animation budgets
use console 60-FPS units` in the log. The user confirmed that the fix resolves the previously failing level 2-2 soccer-enemy encounter.
The previous performance build was confirmed working in user testing. That does not verify
this merged build's headset gameplay or every upstream change. Earlier sessions at 72 FPS
hit a game assertion in `MupSoccerEnemy.cpp:1650`; the timing fix above resolves the reported encounter in user testing. Upstream's 0.20
World 2 collision fix should not be assumed to fix that separate assertion. For comparison
with original game timing, use a 120 Hz headset with a 60 FPS cap. A crash inside the installed
SteamVR runtime during disconnect/session cleanup also remains unverified as fixed.
Public packages contain generic configuration and no game, personal saves, keys or logs.
The upstream documentation below explains its settings and behavior; its default frame cap
may differ from the fork package's initial choice.
The second way to play (the first, the app that runs on the headset itself, is in
`README-QUEST-VR.md`): the emulator runs on this PC, and the picture goes to the Quest 3 the
way any PC VR game does, through Virtual Desktop. The PC has far more to give than the
headset: the game runs at the console's own 60 frames a second with the console's
multisampling, and draws far larger than the console ever did: 2880x3072 an eye by default,
four times the pixels of its largest size (1440x1536, what a PlayStation 4 Pro uses), chosen
in a small window at every start.
## Playing
1. **The controller goes to the PC**, not to the headset: a USB-C cable, or Bluetooth (hold
Create and the PS button until the light bar flashes, then add the device in Windows'
Bluetooth settings). A DualSense remembers one partner: after playing on the headset's own
app it has to be paired with the PC again, and the other way round. Connected to the PC it
gives everything the game uses: buttons, sticks, touchpad, motion sensors, rumble, light bar.
**Paired with the headset instead, it does not work properly**: Virtual Desktop then hands
the PC a copy of it, a plain DualShock 4 without motion sensors and without touchpad - the
controller in the game neither turns nor follows you, and the end of a level (swiping Astro
out of the controller) cannot be done. That is what happened in the first session on
2026-10-02 (the log says "PS4 Controller"; the console window now warns about it).
**The headset's own controllers play as well**: with no gamepad on the PC, and with one
whenever they were used after it (see below).
2. **Virtual Desktop Streamer** has to be running on the PC (the launcher starts it if it is
not), with its own OpenXR runtime, VDXR, as the PC's OpenXR runtime (Streamer window,
Options). It is, on this PC.
3. In the headset, start **Virtual Desktop** and connect to the PC. In its Streaming settings
the frame rate to pick is **120**: the game then draws 60 frames a second and each is
shown for two refreshes, as on a PlayStation VR. (At 90 it draws 45, at 72 36: see "Speed".)
4. On the desktop you now see in the headset, start **`Play Astro Bot VR.bat`** (in this
folder). It looks for the game in the `games` folder next to it, up to three folders
down: an unpacked game (a folder with `eboot.bin` in it) or a `.pkg` package, which it
offers to unpack there first (PkgTool does it, in about a minute; only a package made
from a dump can be unpacked, not an encrypted one from the PlayStation Store). When it
finds neither, a window asks where the game is, and what is chosen there is kept as
`game=` in the settings. A small window comes up next: the **resolution** of each eye (a slider, from the
console's 1440x1536 up to 3600x3840), the **most frames a second**, the **language of the
game** (Windows' own, or one of the game's 28), and the **field of
view**; Play starts the game with them (they are kept in `pc-vr\settings.txt`; untick "Show
this window at every start" to go without it). A console window then says what is found
and what happens; the game's window opens behind it and shows both eyes' pictures side by
side, and after a few seconds the headset switches from the desktop to the game.
5. Pick up the controller. Where it is in the game comes from your hands: put the headset's
own controllers aside (hand tracking on in the headset) and the headset sees your hands
around the gamepad. Virtual Desktop has to pass hand tracking on to the PC (its setting for
forwarding tracking data); where it does not, the emulator takes the places Virtual
Desktop gives the Touch controllers it makes up from the hands while the real ones lie
unused. With neither, the controller floats at a fixed spot in front of you and only turns
with the gamepad's motion sensors. The console window says which it is ("The gamepad is
placed in the game by where the hands are, which ...").
The other order works too: start the game at the PC and put the headset on afterwards. The
game waits up to a minute for the headset (`wait` in the settings) and starts in it; after
that it starts on the monitor and moves to the headset whenever Virtual Desktop connects.
For people watching on the monitor, choose **Desktop view > Single eye (spectator)** in the
launcher. It shows the complete left-eye picture with its proportions kept, leaving black
bars where needed. The headset still gets both eyes
at full resolution; the desktop reuses an existing eye image rather than rendering another
camera. It follows the player's head, not a separate spectator camera. **Stereo (both eyes)**
restores the original desktop view and remains the default. This setting is for the PC build;
the standalone Quest app and external VR-host transport keep their stereo output.
In the game:
- The first screen asks to move the controller into a floating outline. Hold the controller
up in front of you, where the outline is. (A gamepad without hand tracking is assumed to be
there and the screen passes on its own.)
- The world map is selected by **looking** at a planet and pressing ✕.
- **Resetting the view**: hold OPTIONS for a second (as on a PlayStation VR), or press the PS
button. Where your head is then is where you sit as far as the game goes, and the way you
face is straight ahead. The emulator does it by itself when ✕ is pressed for the first time
after the headset came up, and when the headset's own "reset view" is used (hold the Meta
button).
- **Turning round without turning yourself**, for whoever sits where they cannot: hold
**L1** (with the headset's controllers: the left grip) and flick the right stick to a
side. The view turns 30 degrees that way with each flick, about your head: you stay where
you are in the game and face another way, and a gamepad that nothing tracks comes round
with you. The game has no use for L1 while it is played. Resetting the view faces you
straight ahead again. (`turn=45` in the settings for another step, `turn=0` for none.)
- **A controller without a touchpad** (an Xbox pad, the headset's own controllers) has
buttons for what the game wants done on one:
- **right trigger (R2): the pad pressed**, for as long as the trigger is pulled. That is
how the water cannon and the machine gun fire.
- **right shoulder button (R1; the right grip of VR controllers): a swipe forward**, once
for each press. That shoots the hook, throws the stars, opens the chests.
- **left trigger (L2): a pull back** that is held while the trigger is, and let go when it
is. That is the catapult at the end of every level: point the controller at the goal,
pull, let go. It also pulls on the hook's rope.
The game has no use for these buttons while it is played, and is still told of them (a few
of its menus have). Besides them, the right stick is a finger on the pad, for anything
else: it comes down behind the pad's middle, is dragged the way the stick is pushed, and
lifts where the stick was let go; the Back / View button presses the pad. The right stick
does that with a DualSense as well while nothing touches its pad. (A stick that drifts,
never resting at its centre, is left out: it would hold a finger on the pad for good.)
- **Where the game has you blow** into the controller's microphone: blow at the headset's
microphone, or hold the **PS button and □** together, which blows for as long as they are
held. The console window says how loud the game heard the last ten seconds whenever it
heard something ("Microphone: the loudest ... was -18 dB"): the game takes -21 dB for
blowing at half strength and -9 dB for all of it. If blowing gives too little, `mic_gain`
in the settings makes the microphone louder for the game.
- **Taking the headset off pauses the game**, the way the console does it: the picture goes
black and the game waits; it goes on where it was when the headset is back on. The same
happens while Virtual Desktop shows the PC's desktop instead of the game, and when the
connection to the headset breaks: the game waits, and goes on when it is shown again.
(If it ever waits although the headset is on and shows the game: `pause=0`.)
- To quit, close the game's window (or the console window).
### The gamepad in the game, where nothing tracks it
Without tracked hands (Virtual Desktop without hand tracking passed on, SteamVR, Pimax...),
the gamepad in the game hangs before you: 17 cm below your eyes and half a metre ahead,
which is where the game looks for it when it starts. That is in the way of the view for
some, and too high or too low for what the controller has to be held to elsewhere. **Hold
the PS button** and
| press | to |
| --- | --- |
| D-pad up, down, left, right | move the gamepad that way, 2 cm a press |
| L1, R1 | bring it nearer, push it farther |
| △ | switch between your place for it and the standard one |
The first of these puts the gamepad at your own place (30 cm below the eyes and 45 cm ahead
until you move it) and every press is kept for the next time (`pc-vr\user\vr_controller.json`).
Every start begins at the standard place, because the game's first screen needs the
controller there: PS + △ brings it to yours afterwards. The PS button pressed and let go by
itself still resets the view. With tracked hands, the hands say where the gamepad is as
long as they are seen; your own place counts while they are not.
### With the headset's own controllers
The two Touch controllers are the gamepad while no gamepad is connected to the PC, and,
while one is, from the moment they are used:
| Touch controller | DualShock 4 |
| --- | --- |
| left stick | left stick (move) |
| A (right hand) | ✕ (jump, confirm) |
| B (right hand) | □ (punch) |
| X (left hand) | ○ (back) |
| Y (left hand) | △ |
| right trigger | the touchpad pressed, for as long as it is pulled (water, guns); also R2 |
| right grip | a swipe forward on the touchpad, once (hook, stars, chests); also R1 |
| left trigger | a pull back on the touchpad, let go when the trigger is (the catapult at the end of a level); also L2 |
| right stick | a finger on the touchpad, dragged the way the stick is pushed |
| right stick pressed in | the touchpad pressed |
| X and Y together (left hand) | blowing into the microphone, for as long as they are held |
| left grip | L1; held, the right stick flicked to a side turns the view a step |
| menu button (left hand) | OPTIONS |
| left stick pressed in | L3 |
| both sticks pressed in | resets the view |
**The right controller is the controller in the game**: where it is and where it points is
where the game's DualShock is, tracked fully (a gamepad only gives its turning, and its
place as far as hand tracking sees the hands around it). At the first screen, hold the right
controller into the outline; gadgets shoot where it points. `controller_hand=left` in the
settings makes it the left one (the right grip then turns the view). The game's rumble goes
to both controllers.
**With a gamepad on the PC as well, whichever was used last plays.** A button of the
headset's controllers that plays (A, B, X, Y, a stick pushed or pressed in; not a trigger, a
grip or the menu button by itself, which a hand also does to a controller it only rests on)
takes the game over once the gamepad has said nothing for a quarter of a second, and the
gamepad takes it back with its first button, stick or touch. The console window says each
change. Until 0.18 a gamepad that was merely connected, lying on a desk, kept the headset's
controllers from doing anything. One case stays as it was: a gamepad that a streaming
program makes of the headset's controllers themselves (Virtual Desktop's gamepad emulation)
says what they say as they say it, and is then the one that plays.
## The game's versions
The emulator knows two builds of the game's executable from inside: the one on the disc
(1.00) and the last update (1.04). It tells them apart by what the executable holds when it
is loaded, not by what a package's name or `param.sfo` says, and the console window names
the one it found (`CUSA12392 in a build known from inside: ...`). Any other build is left to
itself: it plays at the console's sizes, and in slow motion where frames take long.
- **A copy of the game that has the update in it** (one package, or one folder): put it in
`games` as it is. Keep the whole folder together: an updated `eboot.bin` alone in an
otherwise incomplete folder is not the game.
- **The game and its update as two packages**: the launcher unpacks the game's own package
and leaves the update alone (an update holds only the files it changed, and is turned down
if it is offered as the game). The game then plays as on its disc. To play it updated, the
update gets a folder of its own next to the game's, named `CUSA12392-UPDATE`, which the
emulator lays over the game's files as shadPS4 does; the game's own folder is not changed.
With PkgTool, which is in the `pc-vr\pkgtool` folder, from the AstroQuest folder in
PowerShell:
```powershell
$tool = ".\pc-vr\pkgtool\PkgTool.exe"
$update = "C:\Games\astrobot-update-1.04.pkg"
& $tool pkg_extract --passcode ("0" * 32) $update ".\update-unpacked"
Move-Item ".\update-unpacked\uroot" ".\games\CUSA12392-UPDATE"
# The update's description of itself is kept apart in the package: its number is on the
# PARAM_SFO line of the list (10 in the 1.04 update tried here).
& $tool pkg_listentries $update
New-Item -ItemType Directory -Force ".\games\CUSA12392-UPDATE\sce_sys" | Out-Null
& $tool pkg_extractentry --passcode ("0" * 32) $update 10 ".\games\CUSA12392-UPDATE\sce_sys\param.sfo"
```
Start the game as always: the launcher still names `games\CUSA12392\eboot.bin`, and the
emulator runs the update's.
- **Saves** are in the same place for both versions (`pc-vr\user\home\1000\savedata\CUSA12392`).
A save made by 1.00 loads in 1.04; whether one written by 1.04 loads in 1.00 has not been
tried, so copy that folder before changing versions. To go back to 1.00, move
`CUSA12392-UPDATE` out of `games`.
Tried here: both versions on the PC and, without wearing it, on the Quest 3 (title, the
controller's screen, world map, into a level; 1.04 at 2880x3072 an eye on the PC).
ODevStudio played 1.04 through its first level on a Valve Index with a fix of their own for
the same thing (pull request #15), whose write-up of the folders this section follows. 1.00
is the version played the most.
## Desktop Spectator View
The launcher offers **Stereo**, **Single eye** and **Combined eyes**. Single eye shows the
complete left-eye picture. Combined eyes keep that eye as the main picture and add the
other eye's non-overlapping peripheral strip. This is an experimental composite, not a
separate spectator camera: nearby objects can disagree at the join because the eyes are
in different places. Combined eyes preserve the source eye's proportions and widen the
desktop canvas by the extra peripheral coverage. With symmetric horizontal projections,
Combined eyes have the same aspect ratio as Single eye and add no peripheral view.
These desktop choices leave the headset's stereo picture unchanged.
By default, the desktop preserves the complete image with black bars as needed, without
stretching or cropping. **Crop top/bottom to fill** scales Single eye or Combined eyes to
the desktop width and crops the top and bottom equally on a wide monitor. It does not
stretch the image, add scene coverage or change the headset. Narrow windows can still
have bars above and below; the sides are never cropped. Stereo ignores this option.
Fullscreen follows shadPS4's native `GPU.full_screen` setting in
`pc-vr\user\config.json`; the launcher does not override it or save a separate preference.
Sony describes the original PSVR's standard TV social screen as an undistorted, cropped
right-eye image ([official FAQ](https://blog.playstation.com/archive/2016/10/03/playstation-vr-the-ultimate-faq/)).
Games could also supply a separate TV image; Astro Bot-specific use of that path has not
been confirmed here. Our default spectator option preserves the complete image; cropping
is optional.
## Settings
`pc-vr\settings.txt`, one `key=value` a line (the file explains each):
| Setting | Meaning |
| --- | --- |
| `resolution=2880` | the width of each eye's picture: `1440` (the console's largest), `1800`, `2160`, `2520`, `2880` (default), `3240`, `3600`; or `game` for the console's sizes chosen by the game. The game draws a step smaller by itself while the graphics card falls behind |
| `dynamic=0` | hold the game to the chosen size even where the graphics card falls behind |
| `fps=60` | the most frames a second: `120`, `90`, `72`, `60` (default), `45`, `40`, `36`, `30`; what the headset's refresh rate allows (see "Speed") |
| `fov=100` | how much of the headset's field of view the game draws, 70 to 100 percent: 100 fills all of it; less puts the same pixels over fewer degrees, sharper, with a dark border |
| `fov_of=psvr` | `fov` is a percent of a PlayStation VR's field of view (100 by 103 degrees an eye, what the game was made for) instead of the headset's own (`fov_of=headset`, the default) |
| `menu=0` | no window with the main settings at the start |
| `desktop_view=spectator` | one complete eye on the monitor; `combined` adds the other eye's peripheral strip; default `stereo` keeps both eyes side by side. Does not change the headset view |
| `desktop_crop=1` | scale Single eye or Combined eyes to the desktop width, cropping the top/bottom if needed; default `0` preserves the complete image. Stereo and headset output are unchanged |
| `sharpen=0.3` | sharpening of the picture on its way out, 0 to 1. Virtual Desktop has its own on top |
| `msaa=4` | the most samples a pixel gets; default as the console draws it. With `1` the emulator smooths edges itself (unless `antialias=0`) |
| `hands=0` | do not use hand tracking to place the controller |
| `predict_ms=20` | how far beyond the next picture the head position given to the game is predicted (0 to 80 ms) |
| `stick_touchpad=0` | the right stick no longer doubles as a finger on the touchpad |
| `mic_gain=3` | what the microphone hears, that many times louder for the game (0.1 to 30): for when blowing does too little |
| `controllers=0` | the headset's own controllers never stand in for a gamepad |
| `controller_hand=left` | which Touch controller is the controller in the game (default `right`) |
| `language=fr-FR` | the language the game is played in, as Windows names languages (`fr-FR`, `de-DE`, `pt-BR`, `ja-JP`...); default `windows`: the one Windows is shown in. The game has 28; it is in English for any other. The launcher's window has the list |
| `turn=45` | how many degrees the view turns for each flick of the right stick with L1 held (default 30; `0`: never) |
| `pause=0` | the game is never made to wait when the headset is off the head or shows something else |
| `surround=0` | fold the game's surround sound down instead of rendering it for the headset's speakers |
| `real_time=0` | let the game count time in frames, as on the console |
| `pace=1` | the older way of saying `fps`: refreshes of the headset a frame is given (takes the place of `fps` when set) |
| `wait=60` | seconds to wait for a headset before starting on the monitor (`0`: start at once, move over when it connects) |
| `headset=0` | do not look for a headset, play on the monitor |
| `game=...` | where the game is, if not in the `games` folder: its `eboot.bin`, its folder, or its `.pkg` (which is unpacked into `games`). The launcher's "Where is the game?" window writes this line itself |
| `env=NAME=value` | extra environment variable for the emulator |
The emulator's own settings (window size, input bindings, log) are in `pc-vr\user\config.json`
and `pc-vr\user\input_config\`. Saves are in `pc-vr\user\home\1000\savedata\CUSA12392` and are
the same format as on the headset: `tools/quest-save-to-pc.sh` copies the save of the
headset's app over (headset on the cable; nothing on the headset is changed, and a save the
PC already has is kept next to the new one).
**Until 2026-10-03 the PC build never read its save**: every start was a new adventure. The
game's thread that looks for system events started before the one that starts the user
service, the emulator handed it the player's login anyway (a console refuses until the service
is started), the game counted the player twice, took them for one who had come back, and kept
the empty save it had in memory instead of reading theirs. Fixed in the emulator; checked with
the headset's save (World 1, levels 1 to 3 done): the title offers CONTINUE and the world map
has the 14 rescued bots.
## What you get
| | PlayStation 4 | Quest 3 on its own (app 0.7) | PC through Virtual Desktop |
| --- | --- | --- | --- |
| Frames a second | 60 | 30 in the levels, 45 in light scenes | 60 (at 120 Hz), up to 120 by the setting |
| Scene size per eye | 960x1080 to 1440x1536 | 816x870 to 1200x1280 | up to 2880x3072 (default), 3600x3840 at most |
| Edges | multisampled | smoothed by a filter | multisampled |
| Head movement | 120 Hz, reprojected | 90 Hz, reprojected | the stream's rate, reprojected in the headset |
| Field of view per eye | PlayStation VR's, 100 by 103 degrees | PlayStation VR's (85% of it from app 0.9 on) | the headset's own, all of it (the simulated Quest 3: 94 by 99 degrees) |
The game is told the field of view of the headset being worn, as the emulator finds it when
the session starts, and draws exactly that: the picture reaches every edge of the view (with a
PlayStation VR's, as on the console, a Quest 3 would show a narrow dark margin far out to the
sides, while degrees beyond its view on the nose side, top and bottom were drawn for nothing).
What the PC cannot change: the picture reaches the headset as a video stream, with the
softness and the delay that brings (Virtual Desktop's own settings decide those: bitrate,
codec, sharpening).
Measured on this PC (Ryzen 7 9800X3D, RTX 5070 Ti) with a simulated Quest 3 at 120 Hz
standing in for the headset (see "Testing without the headset"): title, prologue, world map
and the first level, its heaviest view included (1800 draws and 8 million vertices a frame),
at 60.0 frames a second with every frame shown for exactly two refreshes, the GPU busy 5 to
15% of the time at 1440x1536. Level 1-4 at 2160x2304 an eye: 60 frames a second, the GPU busy
20%; at 2880x3072: 60, busy 29%.
**How the larger sizes come about.** The game has a list of sizes it draws its scene at
(816x870 to 1440x1536 an eye) and makes the pictures it hands to the headset 1440x1536; it
sets aside 200 MB for its render targets and 872 MB of graphics memory for everything. With
`resolution` above 1440 the emulator writes larger sizes into the game as it loads (every
size of the list grown by the same factor, the pictures for the headset too, the pools and
the memory they come from), and gives the emulated console that much more memory: the game
then draws everything at that size itself - a sharper picture, not an enlarged one.
## Speed
The emulated PlayStation VR refreshes when the real headset's picture is due (the runtime
tells: once for every picture it wants), and the game draws one frame for every two
refreshes, as it does on the console. With Virtual Desktop at 120 Hz that is the console's 60
frames a second; at 90 Hz it is 45 and at 72 Hz 36, each frame shown for exactly two
refreshes, and the game is given the time its frames really take, so it runs at its proper
speed whatever the rate (see "Speed" in `README-QUEST-VR.md`). Where the runtime only asks
for 60 pictures a second or fewer (a 60 Hz stream, or Virtual Desktop's Synchronous Spacewarp
making up every other picture itself), the game is given one refresh for a frame instead of
two, which keeps it at 60.
**More than 60 frames a second** (`fps` above 60): the game may draw a frame for every picture of
the headset. A frame takes this PC 9 to 11 ms whatever the scene (it is the emulator's
processor work that takes the time; the graphics card is busy 13% of it), so 72, 80 and
90 Hz are within reach and 120 is not: there the emulator tries once, finds that frames do
not fit, and stays with every other picture (it tries again every ten minutes; a try is a
few seconds of uneven frames). With the simulated headset at 90 Hz the walk through the
first level held 90 frames a second throughout. The same scripted walk, played at 60 and at
90 frames a second (`tools/pc-rate-compare.sh`), does the same things at the same moments:
the game's own time step follows the frame rate. What does not are the few things the game
counts in frames: an animated sign in the level runs half as fast again at 90. That, and
that nothing but the first level was compared, is why it is a setting and not the way
things are: 120 Hz with the game's own 60 frames is what the game was made for.
**Hitches**: in play the longest wait for a frame is 20 to 25 ms (a frame is 16.7); where
scenes change (the title, the map, a level loading) single waits of 50 to 235 ms happen, six
in the five and a half minutes from the start into the first level. The head's movement
does not wait for them: the picture that is there keeps being turned by the compositor.
## How it fits together
```
PC
└─ shadps4.exe (the emulator, one process)
├─ the game's x86-64 code, run directly; PSVR, tracker, camera, sound emulated as on the Quest
├─ Vulkan on the PC's GPU; every frame the two eyes' pictures are put side by side
│ ├─ at full size into an image of the OpenXR host (the headset's picture)
│ └─ scaled into the window (what the monitor shows)
└─ OpenXR host (thread "shadPS4:XrHost", src/core/vr/openxr_host.cpp)
├─ session with the PC's OpenXR runtime on the emulator's own Vulkan device
├─ every picture the runtime asks for: head pose, hands and controllers in, the newest
│ frame out as a projection layer with the pose it was drawn for and the PSVR's
│ field of view
└─ the runtime's compositor turns it to where the head is by the time it is shown
└─ Virtual Desktop Streamer ── video ──► Quest 3 (Virtual Desktop app)
```
The OpenXR host is to the PC what the Quest app is to the headset, without the process
boundary: no sockets and no shared buffers, the frame is one GPU copy away. The rest of the
emulator does not know which of the two it talks to.
Things that had to be right, for whoever works on this again:
- **The Vulkan device has to suit the runtime.** The runtime names the instance and device
extensions it needs and the graphics card the headset hangs off; the emulator adds them
when it makes its device (`vk_platform.cpp`, `vk_instance.cpp`). A headset that connects
after that still works: the extensions the common Windows runtimes need are enabled
whenever a runtime is installed.
- **The headset queue is separate when available.** OpenXR uses queue 1 of the renderer's
graphics family when that family offers more than one queue. Runtime calls and headset
copies synchronize on their own mutex, so a blocking runtime call cannot hold up the
emulator's queue 0 submissions. The copy waits on the producer's timeline semaphore;
its fence must finish before the source image is returned to the renderer. Devices with
only one graphics queue retain the shared queue and `Scheduler::submit_mutex`.
`env=SHADPS4_XR_SHARED_QUEUE=1` selects that shared path for comparison. Every ten seconds,
`Headset queue ...` reports the end-frame mutex wait separately from the runtime call.
- **sRGB.** A runtime takes an 8-bit image that is not of an sRGB format for linear light and
shows it too bright. The headset's picture is drawn into an sRGB image (the eye pass
converts back to linear light at its end, `linear_out` in `post_process.frag`) and copied
to the runtime's sRGB image unchanged.
- **Between two frames of the game** the last picture is submitted again with its pose; the
runtime keeps using the image released last.
- **The headset coming off** shows as the session leaving the "focused" state. The emulator
then tells the game what the console tells it (`sceSystemServiceGetStatus`: the system's
own screen is over the game), which is what makes this game pause. (The headset's own
"worn" flag, `hmuMount`, does nothing to it.) While it waits, the game hands over a black
picture of one pixel an eye; the host shows nothing then instead of asking the runtime for
images of that size.
- **A runtime that asks for no pictures** shows something else in the headset: the game is
told to wait, as when the headset is off.
- **A headset that goes away** is not announced by Virtual Desktop's runtime (its source: a
lost headset only makes it stop asking for pictures, for as long as the session lives;
but it also asks for none while it shows the desktop, which passes by itself, and a new
session pulls the headset back into VR). So the host makes a new session when the headset
was put on and no picture has been asked for since, nor is for 3 seconds; when the
headset is off and nothing was asked for for 30 seconds (either less and less often, up
to every 5 minutes, while it changes nothing); or when the session's calls only fail for
2 seconds. A runtime that does announce a loss is believed at once. The game is paused
from the loss until a new session is asked for pictures with the headset on the head.
- **The headset's controllers** are read through one OpenXR action set bound to the Touch
profile (which is also what Virtual Desktop's runtime offers for every controller), made
with each session, and handed on as the first player's gamepad the way a scripted or
remote gamepad is (`GameController::ApplyRemoteState`); the aim pose of one of them is the
tracked controller (`Runtime::UpdatePad`).
- **Sound** goes to Virtual Desktop's own sound device ("... (Virtual Desktop Audio)")
whenever that is in the system, which it is while Virtual Desktop streams to a headset,
whichever device Windows prefers; else to the device the runtime names for the headset;
else to the one Windows plays on. The game's 7.1 mix is rendered for two speakers at the
ears as on the Quest. The microphone goes the same way.
The sound follows its device: when that goes away the sound moves to Windows' default
device, and back when it is there again (see "Fixed in 0.13"). Which device is the
headset's is asked of the runtime again whenever a sound device comes or goes: Virtual
Desktop has its own only while it streams to the headset, and names the PC's until then.
Every port plays on a device of its own, never on "the default device" as such (see
"Fixed and added in 0.19").
## Fixed in 0.20 (2026-10-06): two things in world 2
- **An invisible wall at the end of level 2-1** (issue #16): the hero stood in the air above
the last mound and could not get to the last enemy; the island below had the same. The
collisions of those two islands were most of a block above what was drawn. The game moves a
collision by a speed worked out with one frame's time step and applied for the next
frame's, which is the same thing only where every frame's step is the same, as on the
console. Its physics now take each step with the time step the collisions were moved for
(`README-QUEST-VR.md`, "Speed"). Played to the end of 2-1 on the PC.
- **Squares in the lava's glow around the octopus** at the end of world 2, lit and dark,
for as long as he moved. The game has one buffer bound as two colour targets of the draws
that glow, which Vulkan leaves undefined; such a draw is now made once for each of the two
(`README-QUEST-VR.md`, "The picture"). Played on the PC in the headset.
## Fixed and added in 0.19 (2026-10-06), from what players wrote elsewhere
Reports from the project's thread on Reddit and from the comments under the videos about it.
- **The launcher asked for the Visual C++ runtime again and again**, however often it was
installed; one player had to cut the check out of the launcher to play. Started from a
32-bit program (a file manager, a game launcher), `Play Astro Bot VR.bat` got a 32-bit
PowerShell, to which Windows shows the 32-bit system folder under the name of the 64-bit
one: and one of the runtime's files, `vcruntime140_1.dll`, exists in 64 bits only. The
launcher now hands over to the 64-bit PowerShell first (which also puts the OpenXR
runtime's registry entry where it looks), names the files it misses, and its question has
a third answer: start the game all the same.
- **The game was in English whatever the console's language should have been.** The
emulator's console was set to English and nothing offered another. The launcher now says
which language Windows is shown in, and its window has the game's 28 to choose from
(`language=`); the game takes it as it takes a PlayStation's.
- **The headset's controllers did nothing while a gamepad was connected to the PC**, even
one that lay unused. Whichever was used last plays now: see "With the headset's own
controllers".
- **Turning the view by steps**, asked for by a player who sits where they cannot turn
round: L1 (the left grip) held, the right stick flicked to a side. See "In the game".
- **The game stopped with "Unhandled Exception code 0xc0000005" some time after the headset
was connected, and until then its sound came from the PC's speakers**, when the game was
started before Virtual Desktop streamed to the headset. Two things, one leading to the
other:
- Virtual Desktop's sound device only exists while it streams. Asked for the headset's
sound device before that, its runtime names what Windows plays on, the PC's speakers,
and the emulator asked once, at the start. It then held on to the speakers by name, and
took the sound back to them a few seconds after Windows had moved it to the headset. The
runtime is asked again now whenever a sound device comes or goes, and the sound goes
where it then says. And since what that runtime names is the device Windows prefers,
which need not be Virtual Desktop's even while that one is there (Windows keeps to the
device that was chosen last by hand): Virtual Desktop's own device is taken for the
headset's whenever it is in the system, whatever Windows prefers and whatever the
runtime in use (SteamVR names none).
- That taking back is what stopped the game. Ports opened on "the default device" are
moved by SDL, the sound library, when Windows changes its default; SDL 3.5.0 puts each
moved port at the front of the new device's list without telling the port behind it
(`SDL_DefaultAudioDeviceChanged` leaves that one's back link as it was), and closing
one of them afterwards leaves the list pointing at freed memory, which the device's
playback thread reads next (`SDL_GetAudioStreamDataAdjustGain`). The emulator's ports
no longer play on "the default device": each is opened on the device Windows plays on
at that moment, as the device it is, and moved by the emulator itself, three seconds
after Windows chose another.
Tried by making another device Windows' default while the game plays and the first one
again after (`tools/pc-audio-default-test.sh`): the build before stops, this one plays on,
on the right device each time; by taking the default device out of Windows and putting
it back (`tools/pc-audio-device-test.sh`, levels measured on both devices); and against a
simulated headset whose runtime names another device after one went away. Virtual
Desktop's own device coming and going needs the headset: not tried yet.
- **A question in a box behind the game's window at the very first start** ("Save
Migration": whether to move saves over from where an older shadPS4 kept them), which held
the game up until somebody saw and answered it. The packages avoided it by bringing the
folder it is about; started any other way, it came. It is only asked now where there is
something to move.
## Linux, through Proton, as reported
There is no Linux build of the PC version, and none of this has been tried here. A player
(klejmanm, on Reddit) reports the Windows build running on Arch and Mint with Radeon RX
6000/7000 cards, shown in a Quest through WiVRn, when it is started by Steam and not by a
script of one's own (Wine's OpenXR layer got in the way otherwise):
1. Steam > Games > Add a Non-Steam Game: `pc-vr/shadps4.exe`, "Start In" the `pc-vr`
folder. In its Properties > Compatibility, force GE-Proton or Proton Experimental.
2. Properties > General > Launch Options, on one line, with your own paths. The launcher
is not used this way, so what it would set is given here:
```
PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES=1 PRESSURE_VESSEL_FILESYSTEMS_RW=/var/lib/flatpak/app/io.github.wivrn.wivrn SHADPS4_OPENXR=1 SHADPS4_XR_WAIT=60 SHADPS4_TITLE_EYE_WIDTH=2880 SHADPS4_VR_FPS_CAP=60 SHADPS4_VR_FOV=100 SHADPS4_VR_FOV_OF=headset SHADPS4_VR_SHARPEN=0.3 SHADPS4_CONSOLE_LANGUAGE=en-US WINE_DISABLE_DNS=1 %command% -g "/path/to/games/CUSA12392/eboot.bin"
```
Before 0.19 the first start also needed the "Save Migration" box answered on the desktop,
or the headset's session timed out; that box no longer comes.
## Fixed and added on 2026-10-06, from the reports on GitHub
- **The game's version 1.04 plays** (issues #1, #3, #5, #7, #10). An updated copy of the game
stopped at "Adjust your position until you fit roughly inside the silhouette. The controller
with the RED light bar should also be in view", over a green picture, with no controller to
be seen. From its first update on, the game asks the tracker to find the controller anew on
that screen and waits to see the controller's status go from calibrating back to tracking;
the emulator's tracker never said either. It now does what a console's does. The updated
executable is also laid out differently, so the emulator did not know it and gave it
neither the larger pictures nor the game's own speed ("resolution stuck at the lowest"):
both builds are known now, 1.00 and 1.04, told apart by what the executable holds when it
is loaded (the console window says "CUSA12392 in a build known from inside: ..."). The
addresses for 1.04 are Clodo76's. Any other version still plays as before: at the
console's sizes, and in slow motion where frames take long.
- **A package that is only the game's update** was unpacked as if it were the game. The
launcher now takes the game's own package first and says what an update alone is. To play
the game with its update, use a copy that has the update in it, or put the update's files
in a folder named `CUSA12392-UPDATE` next to the game's `CUSA12392`: the emulator takes
what is there over the game's own files (shadPS4's way of keeping updates), and the
game's folder stays as it was. On the headset the same goes for
`/data/local/tmp/astro/games/CUSA12392-UPDATE`. Without that folder the game plays as on
its disc, 1.00, which is the version tried the most here.
- **The game stopped as it started with code -1073741819** (#9) where Windows listed a
gamepad that could not be opened: nobody was logged in then, and the game takes its first
player for granted.
- **With SteamVR, the game stopped as it started** with "Mapping cannot fit inside free
region", code -2147483645 (#14): SteamVR's parts came to lie where the game maps its
memory. The console's memory is now set aside before the headset is looked for (found by
evertec82). And a session that is asked for no pictures is only made anew for Virtual
Desktop's runtime, which needs it; SteamVR's are left alone.
- **The catapult at the end of a level, and the rest of the touchpad, without a touchpad**
(#2): see "In the game". The catapult took luck with a stick, for two reasons. The game
shoots by where the finger is when it lifts, and a stick let go took the finger back to the
middle first. And the game looks at the pad once for every frame it draws: a stick is at
its end within a few hundredths of a second, so the pull began, for the game, where it
ended, the more often the fewer frames it drew (by a model of the game's own rule, one
fast pull in four failed at 60 frames a second and two in three at 30). The stick's finger
now stays where it comes down until the game has seen it there and moves no faster than a
finger does; and three buttons do the three things the game asks of the pad outright:
press it, swipe forward, pull back and let go.
- **The right stick no longer holds a finger on a real touchpad** (#12): a stick that drifts
touched the pad for good, and a real finger's swipes then counted for nothing. If swipes
on a DualSense's touchpad still do nothing, the console window tells what Windows hands
over ("Controller 1 connected: ... touchpad yes") and whether a finger was ever felt ("The
controller's touchpad feels a finger"): a gamepad that reaches the emulator through
something else than itself (Steam Input, DS4Windows, a headset's streaming app) may come
without its touchpad.
- **The gamepad in the game can be moved** where nothing tracks it (#4), and **blowing** can
be seen and replaced (#8): see above.
- SteamVR and Valve Index support and the desktop's spectator views are ODevStudio's (pull
requests #6 and #11).
## Fixed in 0.13 (2026-10-03)
- **No sound until the emulator was started again.** Virtual Desktop takes its playback
device ("Virtual Desktop Audio") out of Windows whenever the headset is not being streamed
to, and puts it back afterwards. A stream opened on a device that goes away plays into
nothing from then on, without an error, and the device that comes back is a new one to the
sound library. The emulator now looks once a second for the device its sound is meant for:
three seconds after that went away the sound moves to Windows' default device, and three
seconds after it is back, to it again. Where the device is Windows' default itself, the
sound is opened on "the default", which the sound library moves by itself. (The waiting is
there because opening a device while Windows changes its default can hang the sound for
good.) The log says which devices come and go ("Audio output added", "removed", "went
away", "is there"). Tested by taking a playback device out of Windows and putting it back
while the game plays, and measuring what each device plays: `tools/pc-audio-device-test.sh`.
(`tools/pc-audio-default-test.sh` changes which device Windows plays on instead, as a
program that streams to a headset does when the headset connects.)
Not changed: the microphone.
- An emulator crash now leaves its call stack in the log (module and place in it).
## Fixed on 2026-10-03 (afternoon)
- **The game stopped at start with the DualSense paired to the PC** ("Unhandled Exception
code 0xc0000005 at 0x800c47a21"): two gamepads were there, the DualSense and the Xbox 360
controller Virtual Desktop makes of the headset's controllers. Each logged in a player of
its own, the game registered both with the PlayStation VR tracker, and the emulator's
tracker refused the second; the game takes that as fatal. Now, with a headset, there is one
player: the best gamepad (a DualSense before anything else) is theirs and the others are
left alone (the console window says "... is not used"); `env=SHADPS4_VR_ONE_PLAYER=0` in the
settings logs in a player for every gamepad again. The emulated tracker also takes up to
four controllers now, as the console's does.
- **Everything in shade too dark** (most visible in level 1-4, where the ground was black):
objects lit by any light probe but a level's first were lit from the wrong cube faces. See
`README-QUEST-VR.md`, "App 0.10".
- **Field of view: 100% is now all that the headset being worn shows**, not a PlayStation
VR's view. The emulator reads the headset's view from the OpenXR runtime when the session
starts (the console window says "The headset shows ... degrees an eye") and tells the game
that, scaled by the `fov` percent. The game asks once, as it starts; should the headset not
have said by then, the emulator waits up to 10 seconds for it, and after that takes what the
headset showed the last time (kept in `pc-vr\user\vr_headset_fov.json`). `fov_of=psvr` in the settings goes back to the
PlayStation VR's view.
## When something does not work
The console window shows the emulator's lines about the headset as they come; the whole log
is `pc-vr\user\log\shad_log.txt` (the start before it: `shad_log.prev.txt`). What to look for:
| Line | Meaning |
| --- | --- |
| `Headset found through VirtualDesktopXR ...` | the runtime has a headset: Virtual Desktop is connected |
| `Waiting up to 60 s for a headset ...` | the runtime has none yet: connect Virtual Desktop |
| `... has no headset connected yet` / `No headset yet` | still none: the game is on the monitor and moves over when there is |
| `Headset session: focused` | pictures are shown and the headset is on the head |
| `The headset is off the head, or shows something else: the game waits` | the game is told to pause, until `The headset is on the head and shows the game` |
| `Frames are shown through 3 images of 2880x1536` | the picture's size and format in the runtime |
| `Headset: the title delivered 60.0 frames a second (20 ms the longest wait for one, 0 waits of more than 50 ms), 60.0 were shown, the headset took 120.0 pictures a second` | every ten seconds: the game's frame rate against the stream's, and how badly it hitched |
| `pictures were shown N degrees from where they were drawn for` | how much the compositor had to turn pictures: a few degrees while the head turns, next to nothing at rest |
| `Hands: both seen N% of the time ... holding the controller N%` | what hand tracking gave |
| `Controller 1 connected: ... (motion sensors yes, touchpad yes, light yes)` | what Windows handed over of the gamepad |
| `Controller ... cannot be opened and is not used: ...` | Windows lists a gamepad that something else holds, or that is just going away |
| `The controller's touchpad feels a finger` | the first touch of the gamepad's own touchpad arrived |
| `CUSA12392 in a build known from inside: 1.00, as on the disc` (or `1.04, the last update`) | the game's executable is one the emulator has its speed and picture fixes for |
| `This build of CUSA12392 is none of those known from inside ...` | another version of the game: it plays at the console's sizes, and in slow motion where frames take long |
| `Microphone: the loudest of the last 10 seconds was -18 dB ...` | what the game heard; it takes -21 dB for blowing at half strength, -9 dB for all of it |
| `Microphone: nothing but silence has come from it so far ...` | no sound at all reaches the emulator: see the line for what to look at |
| `The controller, while nothing sees where it is, is held to be at ...` | the gamepad's place was moved or switched (PS + D-pad, PS + triangle) |
| `No gamepad is connected to the PC: the headset's controllers stand in for it` | the Touch controllers are the gamepad |
| `Controllers: standing in for the gamepad; the right one tracked N% of the time ...` | every ten seconds: where the tracked controller is and what is pressed |
| `The headset is gone: the game waits until it is back` / `... starting over with it` | the session was lost; a new one is made when the headset answers again |
| `The headset is driven by another graphics card ...` | the PC has two; set `Vulkan/gpu_id` in `config.json` or start with the headset connected |
| `Windows has ... of memory left to hand out, and the emulator asks for about 14 GB` | (the launcher, before the game starts) the emulator has Windows set the console's whole memory aside in one request (`address_space.cpp`), 14 GB in all at 2880x3072. Windows can usually still find it by enlarging its page file, but may refuse while it does, and the emulator then stops as it starts (`The emulator ended with code -2147483645`). Close other programs, or just start again |
No picture in the headset although the session is focused: look for `failed:` lines (frame
calls the runtime refused are counted and logged).
## Testing without the headset
The launcher checks run with `powershell -NoProfile -ExecutionPolicy Bypass -File tools/tests/launcher-test.ps1`.
To check the OpenXR quaternion conversion, parallel and opposite-canted stereo bounds, and
headset FOV cache validation, run these commands from the repository root in a developer shell
with `clang-cl` and the Windows SDK available (the `build` directory must exist):
```powershell
clang-cl /std:c++latest /EHsc /Ishadps4-arm64-main/src /Ishadps4-arm64-main/externals/openxr-sdk/include tools/tests/openxr_view_test.cpp /Fobuild/openxr_view_test.obj /Febuild/openxr_view_test.exe
./build/openxr_view_test.exe
clang-cl /std:c++latest /EHsc /Ishadps4-arm64-main/src /Ishadps4-arm64-main/externals/json/include tools/tests/headset_fov_cache_test.cpp /Fobuild/headset_fov_cache_test.obj /Febuild/headset_fov_cache_test.exe
./build/headset_fov_cache_test.exe
```
To check desktop composition, in the same shell:
```powershell
clang-cl /std:c++latest /EHsc /Ishadps4-arm64-main/src tools/tests/spectator_view_test.cpp /Fobuild/spectator_view_test.obj /Febuild/spectator_view_test.exe
./build/spectator_view_test.exe
```
Virtual Desktop's runtime has no headset to offer unless one is connected, so the OpenXR path
is tested against the **Meta XR Simulator** (installed on this PC), which is a full OpenXR
runtime with a simulated Quest 3 and its controllers. `XR_RUNTIME_JSON` makes one process use
it; the system's runtime stays Virtual Desktop's.
| Tool | What it does |
| --- | --- |
| `tools/pc-xrsim-test.sh <name> <seconds> [NAME=value ...]` | runs the PC build against the simulator, log in `build/dev/xrsim/<name>/`. `XRSIM_HZ=120` sets the simulated display's rate; `SHADPS4_INPUT_SCRIPT=` as for `run-win.sh` |
| `tools/window-shot.ps1 [<title> <png>]` | lists windows, or saves a picture of one (the simulator's shows what its headset shows; the game's shows both eyes) |
| `tools/xrsim-keys.ps1 <key>:<ms> ...` | works the simulator's window: `B` is A and X, `N` is B and Y, `Y G H J` the sticks, `I` the sticks pressed in, `Comma` the menu button (hold keys for a second: short presses are not always seen); `Look:<dx>,<dy>` turns the simulated head, `Click:<x>,<y>` clicks |
| `tools/pc-rate-compare.sh <name> "<NAME=value ...>"` | the same scripted level walk at 60 frames a second and with the given settings, a picture every two seconds from each |
| `tools/xr-probe-win` (`build/xr-probe-win/xr_probe_win.exe`) | what a runtime offers: extensions, system, Vulkan requirements |
| `tools/tests/launcher-test.ps1` | tries the launcher's search for the game (unpacked games and packages, names with brackets, leftovers of an unpacking) on made-up folders |
| `tools/ui-drive.ps1 -Steps "text\|picture.png\|button\|seconds", ...` | works the launcher's own windows and message boxes from outside: waits for one that shows a text, saves a picture of it, presses a button. With a release unzipped somewhere and a package put in its `games` folder, that is the whole first start, from the question about unpacking to Play |
Settings for tests: `SHADPS4_XR_HEAD=0` (the head is a script's to move, the host only
shows), `SHADPS4_XR_FREEZE_AFTER=<s>` (no new pictures after that), `SHADPS4_XR_WAIT=<s>`,
`SHADPS4_XR_HIDE_FOR=<s>` (the headset only turns up that long after the start),
`SHADPS4_XR_LOSE_AFTER=<s>` (the session is taken for lost once),
`SHADPS4_XR_UNWANTED=<from>,<to>` (no pictures are asked for between those seconds of the
session), `SHADPS4_VR_FASTEST_PACE=1` (what `pace=1` sets), `SHADPS4_XR_CONTROLLERS=`
`0`/`1` (off by default under an input script), `SHADPS4_XR_PAD_OFFSET=<x>,<y>,<z>` (where
the gamepad is taken to be from the controller, in metres right, up and towards the player),
`SHADPS4_OPENXR=0`; in input scripts `worn=0` / `worn=1` take the headset off and put it on.
What the simulator runs showed: the session comes up and is focused; pictures arrive at
2880x1536 in an sRGB image and look right in the simulated headset (upright, colours and
brightness as on the monitor, the narrow margin at the outer edge where the PSVR's field of
view ends; since the game draws the headset's own field of view, the simulator's 54/40/50/49
degrees an eye, the layer it is handed has exactly that one); at 72 Hz the game locks to 36.0 frames a second and at 120 Hz to 60.0, following
a change of rate while running; pictures are shown where the head is at rest (0.0 degrees
off), also after the head was turned and after the seat was reset there; the first press of
✕ resets the seat; closing the window ends the session cleanly; the game's window minimised
changes nothing in the headset. A headset that only turns up 50 seconds into the game is
picked up and shown to; a session lost in the middle of the game pauses it, and the new
session shows it again at 60.0 frames a second; a runtime that asks for no pictures for a
while (as Virtual Desktop's does while it shows the desktop) pauses the game for as long,
without a new session. The simulated Touch
controllers start the game (A), move the stick and the touchpad finger, reset the view (both
sticks), and the right one, held into the outline of the first screen, passes it: the game
draws its DualShock where that controller is.
What only a session with the headset on can show: Virtual Desktop's own runtime accepting all
of it (its source says it will: it passes pose and field of view of the layer on, offers the
image format that is asked for, takes the extensions it is given and maps every controller
to the Touch profile), the stream itself, hand tracking through Virtual Desktop, which sound
devices it names, and what it does when the headset is taken off or disconnected (the host
is written to its source, not to its behaviour).
## Building
```sh
source tools/env-win.sh && cmake --build build/win-x64 --target shadps4 # the emulator
bash tools/make-pc-vr.sh # copy it to pc-vr/
```
The OpenXR loader (Khronos OpenXR-SDK 1.1.63, `externals/openxr-sdk`) is built with the
emulator; `ENABLE_OPENXR` (on for Windows) switches the whole of it.
-886
View File
@@ -1,886 +0,0 @@
# Astro Bot Rescue Mission on Meta Quest 3 — what is in this folder
This folder holds a work-in-progress port of the `shadps4-arm64` PS4 emulator core to immersive VR
on Meta Quest, built around one game: ASTRO BOT Rescue Mission (CUSA12392).
| Folder / file | What it is |
| --- | --- |
| `shadps4-arm64-main/` | The emulator core (fork of shadPS4 with a FEX x86-64 CPU backend), with the PSVR emulation and the sound engine added here |
| `quest-host/` | New Quest app: OpenXR presenter + launcher for the core (`com.astrobotquest.vrhost`) |
| `tools/` | Portable toolchain (clang, CMake, Ninja, Windows SDK, Debian arm64 sysroot) and helper scripts |
| `games/CUSA12392/` | The game, extracted from the PKG |
| `build/win-x64/` | Windows x64 build of the core, used to develop and test on this PC |
| `build/arm64/` | The core cross-compiled for the headset (aarch64 Linux/glibc) |
| `build/quest/astro-vr-host.apk` | The app to install on the headset |
| `pc-vr/`, `Play Astro Bot VR.bat` | The other way to play: the emulator runs on this PC and the headset shows it through Virtual Desktop. Everything about it is in `README-PC-VR.md` |
**App 0.10 (2026-10-03 afternoon): the light.** Everything lit by the game's light probes
other than the first was lit from the wrong faces: a level keeps its probes in one cube-map
array, and AMD hardware takes the layer of a cube array as face + 8 x cube while the
emulator's array has six faces to a cube; the emulator passed the number on unchanged. In
level 1-4 the ground, lit by the third probe, read layers past the last and came out black;
everything else lit by a later probe (rock in shade, the controller, Astro) came out darker
or in the wrong colour - in every level, not only 1-4. Fixed in the shader translator
(`FixCubeCoords`); the canyon of 1-4 now shows its red earth and green plants.
**App 0.11 (2026-10-03): the first release on GitHub.** On the headset it does what 0.10
does; it is built from the published source, which by then also had the PC's field-of-view
changes (`README-PC-VR.md`), which the headset app does not use. From 0.11 on the app is
signed with the project's own key: over a build signed with the Android debug key (0.10 and
before) it only installs after that one is uninstalled, which deletes its saves - copy the
`savedata` folder off the headset first.
**App 0.12 (2026-10-03)**: the same app again. What changed is the PC's launcher, which finds
the game in its `games` folder, unpacks a `.pkg` by itself and asks where the game is when it
finds none (`README-PC-VR.md`). The folder it unpacks is also what the headset needs.
**App 0.13 (2026-10-03)**: the same app once more. What changed is on the PC: the sound no
longer stays silent after Virtual Desktop took its sound device away and brought it back
(`README-PC-VR.md`).
**App 0.14 (2026-10-04): startup field-of-view menu.** Each normal launch now shows a popup in the
headset before starting the game. Choose 50–120% with Left / Right on the gamepad's D-pad or
left stick, then press X / A to play. The last choice is remembered; `fov=` in `vrhost.txt`,
when present, supplies the initial choice instead. The popup explains how a narrower render
projection improves pixels per degree at the same resolution and shows the approximate
horizontal angle and average density gain. Below 100%, each eye gets a feathered squircle
(pillowed rectangle) border in the host's existing frame-copy pass. The compositor still
handles lens distortion. At 100% and above there is no added mask. Headless dry-start tests
use the configured FOV without waiting for input.
**App 0.15 (2026-10-04): FOV is relative to the user's headset.** The slider now runs from
50–100%, with 100% filling the headset's own field of view. The host reads its projection
from OpenXR before allowing Play, and passes those full-size optics to the emulator before
the game starts. The angle and PPD estimate use that detected FOV. Reduced settings retain
the soft squircle border; the initial default is still 85%.
**App 0.16 (2026-10-04): align the reduced-FOV apertures with the eyes.** The squircle now
uses the submitted frame's projection to locate straight ahead in each eye texture, instead
of assuming both optical axes sit at the texture midpoint. The detected Quest projection is
asymmetric, so the old masks pointed outward and curved inward over different parts of the
binocular scene. The corrected masks follow the optical axes while preserving the game's
stereo projections and the headset-relative slider. Invalid eye-separation measurements are
also ignored. Validation includes asymmetric shader checks at 50%, 85% and 95%, and a
captured 50% stereo scene whose distant features match the reported projection to within
one pixel.
**App 0.17 (2026-10-04): restore matching binocular coverage at reduced FOV.** Centering the
masks in 0.16 did not fix the unequal inward and outward render bounds introduced in 0.15.
The Quest host now opts into a symmetric render envelope enclosing the detected headset
projection, then scales that envelope for the chosen FOV. Both eyes have the same angular
aperture and squircle boundary. At 100% the envelope covers both full native frusta; OpenXR
clips the excess to the actual headset view. The popup reports visible coverage after that
clipping and the pixel-density gain at the center. Raw headset detection remains unchanged.
Regression checks compare the same angular directions in both eyes, including directions
that were visible in one eye and black in the other at 50% in 0.15 and 0.16.
**App 0.18 (2026-10-06): what the reports on GitHub brought.** The first release since 0.13:
it has the field-of-view menu and the fixes of 0.14 to 0.17 above, and
- **the game's version 1.04 plays.** An updated copy of the game stopped at "Adjust your
position until you fit roughly inside the silhouette", over a green picture: from its
first update on, the game asks the tracker to find the controller anew there and waits to
see the controller's status go from calibrating back to tracking, which the emulator's
tracker never said. It does now. The updated executable is laid out differently as well,
and is known now next to the disc's: the game's own speed and the choice of its picture's
size apply to both (core.log says `CUSA12392 in a build known from inside: ...`). The game
with its update can be one folder, or the update in a folder of its own beside the game's:
`/data/local/tmp/astro/games/CUSA12392-UPDATE` (see "The game's versions" in
`README-PC-VR.md`). Tried on the headset without wearing it, into a level.
- A microphone the app was once refused: the app asks again at its next start; if the
headset no longer shows the question, allow it in the headset's Settings, under the app
permissions for the microphone, for "Astro VR Host". core.log says what the game hears
("Microphone: the loudest of the last 10 seconds was ...").
- **more controllers than the DualSense.** The app was made for a DualSense and took
everything else for a lesser one. Now (all of it under "Controllers" below):
- **the headset's own controllers play the game**, for whoever has no gamepad. The app
reads them through the headset's runtime, with where they are and how they point; the
right one is the controller in the game.
- with a gamepad and the headset's controllers both at hand, **the game is played with the
one used last**.
- **the start-up menu takes whatever is in the player's hands**: a stick to a side chooses,
✕ / A or a trigger starts the game.
- **a gamepad without motion sensors is turned and tilted in the game by the hands that
hold it.** Before, it followed the hands to where they were and kept pointing straight
ahead, which left the game's gadgets nothing to aim with.
- **what the game wants done on the touchpad is on buttons**, on the headset's
controllers and on every gamepad that has no touchpad: the right trigger (R2) presses
it, the right grip (R1) swipes forward, the left trigger (L2) pulls back and lets go,
which is the catapult at the end of every level. A card in the headset says so when the
game starts and whenever OPTIONS is pressed. (The first worn try of this version ended
at that catapult: the stick alone did not shoot it. The game looks at the pad once for
every frame it draws, thirty times a second here, and a stick is at its end before it
has looked twice: the pull began, for the game, where it ended.)
- **the right stick as the touchpad's finger** is one the game can follow now: it stays
where it comes down until the game has seen it there, moves no faster than a finger
does, and lifts where the stick was let go. A button that presses the touchpad touches
it as well.
- smaller things for gamepads that are not PlayStation's: a right stick that reports on
other axes, triggers that are only buttons, a Back button the system would take for its
own "back" and close the app with.
Played in the headset: the headset's own controllers, from the start-up menu into a level
(seen all of the time, the game played with them). The buttons for the touchpad came after
that session, which ended at the catapult; they have been played with on the PC, where
they are the same code, and the catapult goes off there. On the headset they were checked
without wearing it: what the game is given of a pull, a swipe and a press, read by read
(see "Testing on the headset without wearing it"). **Not tried: a gamepad other than a
DualSense**, and with it hands that turn a gamepad without motion sensors.
What else is new is for the PC (`README-PC-VR.md`): SteamVR and other PC headsets, a view
for spectators on the monitor, a gamepad in the game that can be moved where nothing tracks
it, an easier catapult with a stick, blowing with buttons.
**App 0.19 (2026-10-06): what players wrote elsewhere** (the project's thread on Reddit, the
comments under the videos about it).
- **The game speaks the headset's language.** It was in English whatever the headset was set
to: the emulator's console was set to English and nothing offered another. The app now
tells the emulator the language the headset is set to, and the game takes it as it takes a
PlayStation's, if it has it (28 languages; English otherwise). `language=` in the settings
names another. Tried on the PC, where the same emulator shows the game's first screens in
French, Japanese and Arabic; on the headset, without wearing it.
- **Turning the view by steps**, for whoever sits where they cannot turn round (the game has
things behind the player, and expects them to look): the left grip held (L1 on a gamepad;
the right grip with `pad_hand=left`), each flick of the right stick to a side turns the
view 30 degrees that way. The turn is about the head: the player stays where they are in
the game and faces another way, and the controller comes round with them. The game is
told nothing; to it, the player has turned. A reset of the view faces them straight ahead
again. `turn=45` for another step, `turn=0` for none. The card that names the buttons has
a line for it. Tried on the PC against a simulated headset (the view turns, the head stays
where it is); **not worn**.
What else is new is for the PC: a launcher that no longer asks for a runtime that is
installed, and a gamepad and the headset's controllers that take turns there too.
**App 0.20 (2026-10-06): two things in world 2**, both in the emulator and so the same on the
PC.
- **An invisible wall at the end of level 2-1** (issue #16): the collisions of the last two
islands were most of a block above what was drawn. See "A time step that changes, and the
collisions the game moves" under "Speed".
- **Squares in the lava's glow around the octopus** at the end of world 2. See "One surface
as two targets of a draw" under "The picture".
Both were found and tried on the PC, in the headset through Virtual Desktop. The Quest app
has the same emulator code; it had not been run on a headset when this went out.
**App 0.8 (2026-10-03)**, after the fourth session (levels 1-1 to 1-3 played, the game stopped
on entering 1-4 twice):
- **The crash at level 1-4 ("Cliffhanger")**: the level is the first to draw with a geometry
shader fed with a triangle fan, a kind of primitive the emulator's shader compiler had no
case for; it stopped on purpose there (`spirv_emit_context.cpp: NumVertices: Unreachable
code!`, the emulator ends with signal 5). Fans, polygons, line loops and the strips with
adjacency are handled now; 1-4 loads and plays on the PC and in the headset's sandbox with
the player's own save. The controller's vibration just before is the game's: what the game
asks of the motors lasts a second at most once the emulator is gone.
- **Saves**: the game could miss its save at start (the adventure began anew; it happened on
the PC every time, here only by luck of timing): the emulator handed the game the player's
login before the game had started the user service. Fixed; see `README-PC-VR.md`.
- **Head-butting**: the headset's position reaches the game (every ten seconds core.log says
where it has the head: it follows the player's), and from 0.8 the emulator also works out
how fast the head moves where the headset's system does not say, since the game only takes
a push of the head for one above some speed.
- **`fov=`**: a narrower field of view than the PlayStation VR's for a sharper picture (see
the settings below). In 0.8 it defaulted to the console's own, so nothing changed unless
set; **app 0.9 (same day) makes 85% the default**: 18% more pixels to the degree.
## How it fits together
```
Quest 3
└─ Astro VR Host (Android app, OpenXR)
├─ shows the stereo frames as a projection layer; the compositor reprojects them to the
│ current head pose every refresh, like PSVR's own reprojection did
├─ sends the head pose (and lens distance) to the core every display refresh
├─ makes the DualShock 4 the game expects of what the player holds: a gamepad paired with
│ the headset (of a DualSense everything: buttons, sticks, triggers, touchpad and motion
│ sensors in; rumble and light bar colour out) or the headset's own controllers
├─ places that controller in the game: one of the headset's own is where it is; a gamepad
│ is where the hands are that the headset sees holding it (otherwise at a fixed spot in
│ front of the player), turned by its motion sensors or, where it has none, by the hands
├─ plays the sound (one audio stream per port the game uses)
└─ starts the emulator core as a child process ──► shadps4 (aarch64 Linux, glibc)
├─ FEX runs the game's x86-64 code
├─ Turnip (Mesa) Vulkan driver on the Adreno GPU
├─ emulated PSVR: HMD, tracker, camera,
│ reprojection, social screen
└─ sound: native Ngs2 engine, 3D audio,
7.1 rendered for the ear speakers
```
Frames travel from the core to the app in shared GPU buffers (AHardwareBuffer / dma-buf), poses and
controller data over unix sockets, audio over a third socket.
## Building
Everything builds on this Windows PC from Git Bash, no admin rights, WSL or Android Studio project:
```sh
source tools/env-win.sh && cmake --build build/win-x64 --target shadps4 # PC test build
bash tools/build-arm64.sh # core for the headset
bash quest-host/build.sh # APK
```
`quest-host/build.sh` takes the Linux runtime the core needs (glibc, its loader, the Turnip driver)
out of `bachatas4-0.2.4-release.apk`, which therefore has to stay in this folder.
## Installing and running
```sh
adb install -r build/quest/astro-vr-host.apk
adb push games/CUSA12392 /data/local/tmp/astro/games/ # 13 GB, a few minutes
```
To play with a gamepad, pair it with the headset (Settings ▸ Bluetooth; on a DualSense, hold
Create + PS until the light bar flashes); the headset's own controllers need nothing. Then start
**Astro VR Host** from the app library (Unknown Sources). A floating panel reports what the app
is doing, and what the game will be played with, until the game's first frame arrives.
With a gamepad, put the Touch controllers aside: with them out of the way the headset tracks
your hands, which is what places the gamepad in the game. See "Controllers" below for what
plays how.
In the game:
- The first screen asks to move the controller into a floating outline. Hold the DualSense up in
front of you, where the outline is. (If the headset does not see your hands, the controller is
assumed to be there and the screen passes on its own.)
- The world map is selected by **looking** at a planet and pressing ✕.
- Touchpad: the DualSense's own. Without one, the right stick is the finger and Create / Back /
View the press (see "Controllers").
- **Resetting the view**: hold OPTIONS for a second (as on a PlayStation VR), or press the PS
button; with the headset's own controllers, hold the left one's menu button for a second or
press both sticks in. Where your head is then is where you sit as far as the game goes, and
the way you face is straight ahead; the controller's heading is taken anew as well. The app
does it by itself when ✕ is pressed for the first time, and when the headset's own "reset
view" is used (hold the Meta button). Do it again whenever you have settled differently and
things are too close, too far or off to a side: the game takes its bearings only when it is
told to (see "Where the player sits").
- Taking the headset off pauses the game; putting it back on resumes it.
- If the headset's boundary lights up, you are near its edge: besides being in the way, the
boundary costs the GPU time the game needs. A boundary with more room around the seat avoids
it.
Optional settings go in `/sdcard/Android/data/com.astrobotquest.vrhost/files/vrhost.txt`, one
`key=value` per line:
| Setting | Meaning |
| --- | --- |
| `refresh_rate=72` | display refresh rate to ask for (72, 80, 90, 120; default 90). It decides which frame rates the game can keep in step with the display: 45 or 30 frames a second at 90 Hz, 36 at 72, 40 at 80, 60, 40 or 30 at 120, see "Speed" |
| `pace=2` | how many refreshes of the display every frame of the game is given. `2` is the game's own way, as up to version 0.6; a larger number holds it to that. Default: the emulator chooses, see "Speed" |
| `resolution=960` | the size the game draws its scene at, per eye: `816` (x870), `960` (x1080), `1200` (x1280), `1440` (x1536), or `game` for the game's own choice. Default: the emulator chooses, as large as the GPU manages at the pace, see "Speed" |
| `antialias=0` | leave edges as jagged as the game draws them without multisampling. Default: the emulator smooths them, see "The picture" |
| `real_time=0` | let the game count time in frames as on the console: slow motion whenever a frame takes longer than 1/60 s. Default: the emulator has it run by the clock, see "Speed" |
| `hands=0` | do not use hand tracking; a gamepad stays at its fixed spot |
| `own_controllers=0` | never play with the headset's own controllers, whoever holds them |
| `pad_hand=left` | with the headset's own controllers, the left one is the controller in the game instead of the right one |
| `language=fr-FR` | the language the game is played in, as a language tag (`fr-FR`, `de-DE`, `pt-BR`, `ja-JP`...). Default: the one the headset is set to. The game has 28; it is in English for any other |
| `turn=45` | how many degrees the view turns for each flick of the right stick with the left grip (or L1) held (default 30; `0`: never) |
| `pad_tilt=-15` | for a gamepad without motion sensors, which the hands holding it turn and tilt in the game: how many degrees higher (lower, if negative) its front points than the hands do (-60 to 60). For a gamepad that points too high or too low in the game |
| `sharpen=0` | no sharpening of the picture. Default 1: the emulator sharpens every frame of the game once. 2: the headset's Super Resolution filter on every refresh instead (finer; about a twentieth of the GPU's time). 3: that filter whenever the system finds the GPU has time for it (in a level: hardly ever). 4: the headset's plainer filter. See "The picture" |
| `cubic=1` | have the headset enlarge the picture with a cubic filter instead of a linear one (sharper, for some GPU time on every refresh) |
| `stick_touchpad=0` | right stick no longer doubles as a finger on the touchpad |
| `motion=0`, `rumble=0` | ignore the gamepad's motion sensors (the hands holding it then say how it is turned, as for a gamepad that has none) / no vibration |
| `msaa=4` | let the game multisample as on the console (default 1 = off, much faster) |
| `stats=1` | keep a small panel in view with the game's frame rate, the display's refresh rate and whether the hands holding the controller are seen |
| `predict_ms=25` | how far beyond the next refresh the head pose given to the game is predicted (0–80). More if the picture's edges show when turning the head, less if the world wobbles |
| `dynamic_resolution=0` | do not ask the system what size it recommends for the picture (which may cost the GPU its fastest clock). `2`: also show the picture at that size, as up to version 0.6. Default: ask, and show the picture at full size, see "The picture" |
| `cpu_boost=0` | do not ask for the processor's "boost" level (its fastest clock, which the system grants for the first 45 seconds and up to a fifth of the time after) |
| `fov=100` | Initial choice in the startup FOV menu, from 50–100% of the user's headset projection tangents. The last menu choice is used when this setting is absent; the first-launch default is 85. `100` fills the runtime-reported headset FOV and adds no mask. Lower values render the same pixels over fewer degrees, with a soft squircle border around each eye. The menu's angles and average PPD gain are calculated from this headset's detected optics; the chosen FOV is logged in host.log and core.log. |
| `mic=0` | the game does not get to hear the headset's microphone (see "Microphone") |
| `mic_gain=2` | make what the game hears that many times louder (0.1–30) |
| `arg=...`, `env=NAME=value` | extra emulator argument / environment variable |
What a session leaves behind, in the same folder as `vrhost.txt`:
| File | Content |
| --- | --- |
| `host.log` | the app's own log: settings, controller found and what it offers, the OpenXR session, and every ten seconds a line with the game's frame rate against the display's, the processor time and memory the emulator used and the cores it ran on, a line on where the head is in the headset's own space and how far it moved, a line on what was seen of the hands and where the controller was taken to be, every reset of the view and what asked for it, the VR runtime's own counters ("runtime:": GPU load, the compositor's time, frames it had to repeat, load per processor core), and how loud the microphone was |
| `core.log` | the emulator's output, with a few lines every five to ten seconds on the frame rate and what a frame's time goes to, on how each sound port keeps up and on what the game's microphone port heard; "The title's clock" (how long frames take, the time step the game is given, the speed it ran at, the size it draws at, how many refreshes a frame is given and how busy the GPU was), "The scene is drawn at" and "Frames are given ... refreshes" (every change of size and of pace, and why), "The title has the player's seat at" (where the game takes the player to sit and where the head is from there: near zero when the view is right), "Seat recentred" (every reset of the view), "The headset refreshes ... after the host's display" (how the emulated headset keeps step with the real one) |
| `host.prev.log`, `core.prev.log` | the same of the start before the last one |
`adb pull /sdcard/Android/data/com.astrobotquest.vrhost/files/host.log` fetches one; they are
what to look at (or send along) when something did not work. `adb logcat -s AstroVR` shows the
host's log live. The emulator's own folder (saves, `sys_modules`) is `.../files/data/shadPS4/`.
## Controllers
The game is played with one DualShock 4, which the app makes of what the player holds.
**A DualSense** (or a DualShock 4, which the headset's system knows the same way; untested) has
everything the game uses: its touchpad, its motion sensors for how it is turned, rumble and
light bar. Where it is in the game is where the headset sees the hands that hold it.
**Other gamepads** (the headset's system has drivers and key layouts for Xbox controllers,
8BitDo, Logitech, Razer, PDP, Hori and others; nothing but a DualSense has been tried) lack
two things the game is built around, which are made up for:
- *the touchpad.* The game asks three things of it, and each is on a button the game has no
use for while it is played (it is still told of them: a few of its menus have):
- **R2, the right trigger: the pad pressed**, for as long as the trigger is pulled. The
water cannon and the machine gun fire that way.
- **R1: a swipe forward**, once for each press. That shoots the hook, throws the stars,
opens the chests.
- **L2, the left trigger: a pull back**, held while the trigger is and let go when it is.
That is the catapult at the end of every level (point the controller at the goal: the
game only shoots within 25 degrees of it), and it pulls on the hook's rope.
A card in the headset names them when the game starts and again whenever OPTIONS is
pressed, which is the game's pause. Besides them the right stick, for which this game has
no use either, moves a finger over the pad for anything else: the finger comes down behind
the pad's middle, is dragged the way the stick is pushed, and lifts where the stick is let
go (not back in the middle, where a stick flies to). The Create / Back / View button
presses the pad. (`stick_touchpad=0` leaves the stick a stick.)
Why buttons, and why the finger is slow: the game looks at the pad once for every frame it
draws, thirty times a second in a level here, and makes a pull or a swipe of where it saw
the finger first and last. A stick is at its end within a few hundredths of a second, and
a finger that follows it there has not moved at all as far as the game can tell. So every
finger the app moves stays where it lands until the game has seen it, goes no faster than
a real one flicks, and lifts only when the game has seen it at its end.
- *the motion sensors*, by which the game knows how the controller is turned: its gadgets aim
with it. The hands holding the gamepad stand in: the line from one palm to the other is the
gamepad's sideways axis, which gives how it is turned and how far it is rolled, and the way
the hands point is how far its front is tilted up or down. That is as steady as the
headset's view of two hands around a gamepad, and off by however a gamepad's shape has the
hands point: `pad_tilt=` moves it if it points too high or too low in the game. Motion
sensors of gamepads that are not PlayStation's are not used even where the system offers
them: which way is up for a motion sensor is each maker's own affair. (`motion=0` has a
DualSense go by the hands as well. For a gamepad that has both, `host.log` says every ten
seconds how far the two are apart: "hands against the gamepad's motion sensors".)
**The headset's own controllers**, for whoever has no gamepad. The app reads them through the
headset's runtime, which says where they are and whether anybody holds them (the gamepad the
system makes of them for other apps says neither, and is ignored):
| On the controllers | In the game |
| --- | --- |
| the right controller, where it is and the way it points (`pad_hand=left`: the left one) | the controller |
| left stick, pressed in | left stick, L3 |
| A, B (right) | ✕, □ |
| X, Y (left) | ○, △ |
| X and Y together | blowing into the microphone, for as long as they are held |
| right trigger | the touchpad pressed, while it is pulled (water, guns); also R2 |
| right grip | a swipe forward on the touchpad (hook, stars, chests); also R1 |
| left trigger | a pull back on the touchpad, let go when the trigger is (the catapult); also L2 |
| left grip | L1; held, the right stick flicked to a side turns the view a step (see "Where the player sits") |
| right stick | a finger on the touchpad, as on a gamepad without one |
| right stick pressed in | the touchpad pressed |
| menu button (left) | OPTIONS; held for a second, it resets the view |
| both sticks pressed in | resets the view |
(With `pad_hand=left` the trigger and the grip that press and swipe are the left ones, the
right trigger pulls, and the right grip turns the view.) Both controllers shake with the
game's rumble, the left one more with the heavy motor and the right one more with the light
one.
**With a gamepad and the headset's controllers both at hand, the game is played with the one
that was used last**: a button on the other takes over, and `host.log` says so. From a
gamepad, only what a hand cannot do without a controller in it takes over (a button under a
thumb, a stick): while a gamepad is held it is the hands the headset sees, and their pinching
must never count. The start-up menu takes either: a stick to a side chooses, ✕ / A or a
trigger starts the game, and what started it is not pressed in the game.
## Where the player sits
The game takes the place of the player's head once, from the first position the tracker gives
it a few seconds after it starts, and from then on places everything around that spot; it never
asks which way the player faces, because a PlayStation VR player faces the camera. It takes
stock again only when the console says the view was reset, or when OPTIONS is held. (Not when a
level starts: with the head half a metre forward of where it was at the start, the PC build
loads the first level and stays half a metre forward.) On the Quest the first position is
wherever the head was a few seconds after the app was started, in a space whose origin and
heading are the headset system's affair. In the second session that put the player ahead of
where the game meant them to be for the whole session: the hero stays 0.4 to 1.1 m in front of
that spot, so less than half a metre is enough to stand on top of him.
The emulator now keeps the player's seat itself (`src/core/vr/vr_runtime.cpp`): the position and
heading of the head at the moment the view was last reset. What the game is given is counted
from there, and with every reset it is sent what the console sends, so that it takes stock too.
The view is reset when the first pose arrives, when the game first asks for one, when ✕ is
pressed for the first time (the player has settled, controller in hand, looking at the title),
when OPTIONS is held for a second or the PS button is pressed, and when the headset's own reset
is used. `core.log` says each time where the seat is, and every ten seconds where the game takes
the player to sit and where the head is from there.
**Turning round without turning** (0.19) is the seat turned about the head, 30 degrees for
each step (`Runtime::TurnView`): the head keeps its place in the game's world and faces
another way, a controller the headset sees is placed anew from the turned seat, and one it
does not see keeps its place before the player. The game is not told: a player on a swivel
chair would look the same to it. The steps are asked for with a button the game has no use
for in play, held, and the right stick flicked to a side (the left grip, or L1; see
"Controllers"), and are undone by the next reset of the view. `core.log` says each one
("The view turns a step to the right: ...").
## Speed
**The game counts time in frames.** It moves its world on by a sixtieth of a second for every
frame it draws, however long the frame took. On the console that is the same thing; here it is
not, and in the first two sessions the whole game ran at little more than half its speed (30 to
35 frames a second). The emulator now gives it, frame by frame, the time its frames really take
(the engine keeps its step in three variables, written by the one function that sets its frame
rate; `src/core/known_title.cpp`). Slowed down to 43, 30 and 22 frames a second, the PC build
then follows the same course to the second as at 60, and a scripted walk through the first level
has the hero in the same places at the same moments. Below 20 frames a second the game slows
down rather than take steps its physics were never tried with. `real_time=0` turns this off.
**A time step that changes, and the collisions the game moves.** One thing in the game did
depend on every frame's step being the same. The collisions of whatever moves (a platform, a
door, a part of a level that rises) are bodies the game sends to a place: the physics library
gives the body the speed that takes it there in one step, worked out with the time step of
the step it last took, and the next step moves it by that speed times the new time step.
With two different time steps the body lands off by its way times their difference. For what
moves all the time that is put right a frame later. For what is sent once it stays, and
levels do that: they sleep by sections, and a section that wakes sends all its bodies in one
frame from where they slept to where the level has moved since. The last two sections of
level 2-1 wake at the top of a tree that carried them 17 units up while they slept: their
collisions ended most of a block above what was drawn, an invisible wall before the last
enemy (issue #16). The function that takes the step is changed in the game's image, in both
versions of the game, to step with the time step the bodies were sent with
(`src/core/known_title_builds.h`, `PhysicsStepChanges`); bodies then land where they are sent
to the last digit, across hitches too.
**Two refreshes for a frame, or three.** The game draws a frame for every two refreshes of its
headset (60 frames for the 120 Hz of a PlayStation VR) when it manages. When a frame takes
longer it goes straight on to the next one, at whatever rate that makes, and that rate has
nothing to do with the display's: at 33 frames a second on a display of 72 Hz one frame is on
show for two refreshes, the next for three, as they happen to fall, and whatever moves in the
picture moves in jerks. That is what the first level was like up to version 0.6.
The emulated headset refreshes with the Quest's display. The app tells the emulator of every
refresh; the emulated headset keeps step with those and refreshes at the moment after them that
has the game's frames arrive midway between two looks of the app (a clock of its own would
drift against the display). From 0.7 on it refreshes more slowly than the display wherever the
game cannot keep the display's pace: one and a half times as slowly, so that the game's two
refreshes are three of the display's. The app asks for 90 Hz now. That makes 45 frames a second
where the game manages a frame in two refreshes (title, menus, world map, lighter scenes) and
30 where it takes three (the levels), and either way every frame is on show for exactly as long
as the next. In the self-test, with a display and a compositor of 90 Hz standing in, 292 to 300
of 300 frames stay for exactly three refreshes in the first level's ordinary views, and 87 to
96 of 100 in its heaviest. Head movement is smooth at the display's rate regardless: the
compositor turns the last frame to where the head points by now.
Frames are never given more refreshes than still makes 30 frames a second: a game that cannot
do that either is left to draw as fast as it can. At `refresh_rate=72` and `80` there is
therefore only the one pace (36 and 40 frames a second), and the levels run beside the display
as they did; at `120` there are three (60, 40 and 30). `pace=` holds the game to one number of
refreshes throughout.
(What does not work is holding a finished frame back until its refresh: the game does not wait
for its frames to be shown, so they pile up behind the one that is held - a tenth of a second
late on the PC, seconds on the headset. The first attempt did that.)
**The size of the picture.** The game draws its scene at one of four sizes (816x870, 960x1080,
1200x1280 or 1440x1536 an eye; a base PlayStation 4 moves between the last three, and only goes
down to the first in a few levels), enlarges it to 1440x1536 itself and hands that to the
headset. It chooses the size by how long it finds the GPU to take over a frame, and emulated,
the clock it reads for that says how long the emulator took to pass the drawing on, not how
long the GPU needs for it. The emulator therefore chooses in its place, pace and size together
(`Governor` in `src/core/known_title.cpp`). It looks eight times a refresh whether the GPU
still has work of the game's before it, and goes by that and by how long frames take:
- frames that fit the refreshes they are given take exactly that long, on average; frames that
take 3% longer do not fit;
- the pace is the fastest at which 960x1080, the console's own smallest, fits the GPU's time
with a tenth to spare. Only at the slowest pace there is (30 frames a second) will 816x870 do;
- the size is the largest that fits the pace with 15% to spare, by what the sizes were seen to
cost (the three larger ones are counted as 8%, 27% and 54% more GPU time than 816x870, the
upper end of what was measured);
- when frames stop fitting and the GPU is what holds them up (busy 85% of the time or more), it
goes straight to the size, and if need be the pace, that the GPU's time says will fit; when
it is the game's own work that takes long, to the next pace;
- a step up, in size or in pace, that had to be taken back within seconds makes the next one
wait twice as long (6 seconds to 2 minutes), and counts the size as dearer.
Up to 0.6 the size went back and forth between 816x870 and 960x1080 all through the first
level (fourteen times in six minutes of the third session): at 72 Hz the smaller one used 78 to
94% of the GPU's time and the larger one did not fit. With three refreshes of 90 Hz for a frame
there is room. In the self-test's first level (GPU at 690 MHz, compositor stand-in at 90 Hz):
1200x1280 at 30 frames a second with the GPU busy 78% of the time in its ordinary views, 816x870
at 30 with the GPU busy 97 to 99% in its heaviest (the whole level in view), 1440x1536 at 45 in
the world map. A session has a slower GPU clock (599 MHz): expect the heaviest views to fall a
little short of 30 there, and the ordinary ones at 960x1080 to 1200x1280. `resolution=` holds
the game to one size, `resolution=game` leaves the choice to the game.
Measured on a Quest 3 in the first level, without the headset worn (`tools/quest-sandbox-test.sh`):
about 30 frames a second before the changes below; after them 59 at the start of the level (860
draws per frame) and 40 to 42 looking down the whole level (1800 draws), where GPU and processor
are both at their limit. A real session differs: the emulator has three processor cores of its
own there (3 to 5, at 1.92 GHz), the GPU runs at 599 MHz at most (690 in the sandbox), and the
compositor takes 0.7 to 1 ms of it at every refresh. The second session (app 0.5, at 120 Hz):
40 to 56 frames a second in the prologue, 21 to 40 in the first level, with the GPU 97 to 99%
busy and the emulator using two of its three cores. The third (app 0.6, at 72 Hz): 33 to 36 in
most of the level and 24 to 32 at its start, at the game's proper speed. The self-test can
stand in for that: `HOST_COMPOSITOR=90` among its settings takes the GPU away from the emulator
the way the compositor does, which makes the GPU the limit there too (its frame rates times
0.87 for the slower clock are about what a session gets), and `tools/quest-level-bench.sh` runs
it into the first level that way.
Three programs share the GPU in a session: the emulator, this app (which copies each finished
picture to where the compositor takes it from) and the compositor. Left to itself the driver
treats the first two as equals, and the app's millisecond of copying then waits behind everything
the emulator has queued for the pictures after it: in that first session up to 54 ms, with the
app's own frame loop down to 42 rounds a second. The emulator's GPU work therefore runs at the
lowest of the kernel driver's priority levels (`KGSL_COMPAT_PRIORITY=12`, set by the app; the
Mesa driver has no way to ask for that itself, the library that sits between it and the kernel
does it). In the self-test, with the GPU kept busy by 4x multisampling, the copy went from 15 to
38 ms on average (76 at worst) to 1.5 ms (9 at worst), and the game got 15% more frames out.
What made the difference, in the core (`shadps4-arm64-main`):
- The frame's GPU commands are handed to the driver in parts while the frame is still being
translated, not all at once at its end, so that the GPU works at the same time as the thread
that feeds it (`SHADPS4_FLUSH_DRAWS`, 64 draws by default on the headset build).
- Images are created with the list of formats they will be viewed in. Without it Turnip has to
store every colour image uncompressed and untiled.
- A render pass is no longer ended by a change that does not need it (a target that has just
been cleared, depth writes being switched off and on): 113 passes per frame became 52.
- Per-draw work of the GPU thread that only repeated itself is remembered (fetch shader layout,
texture descriptions and lookups, image layout transitions), and a pipeline that is already
bound is not bound again (which made the driver resend its state to the GPU).
- A finished frame is flipped when the GPU is done with it, not at the next refresh.
- The emulator has more busy threads than the headset has cores for it. The sound thread and the
two threads every frame passes through get a higher priority than the game's helpers
(`SHADPS4_THREAD_NICE`, set by the app). (The VR runtime's own thread hints are not available
for them: it only accepts threads of the app's process, and the emulator is a process of its
own.)
- The GPU driver draws directly instead of in tiles (`TU_DEBUG=sysmem`, set by the app while
multisampling is off).
What is left is what the game draws. Looking down the first level that is about 8 million
vertices a frame (shadow maps a third, a depth pass for each eye a fifth, the two colour passes
the rest), in 1850 draws; `SHADPS4_FRAME_STATS=1` logs the count, `=2` lists it pass by pass.
With every draw cut down to one triangle (`SHADPS4_DBG_DRAW_VERTICES=3`, a measuring aid) the
GPU still has a third of its work: about 4 microseconds per draw for the state that changes
between draws, the other two thirds being the geometry and its pixels. The frame rate of that
view does not rise by it, though: there the game's own threads and the thread that translates
its draws (20 ms a frame) are the limit in the sandbox, where the emulator has three cores.
In a VR session the system holds the GPU at 545 MHz at most (level 4), and lets it go to 599
(level 5) only for apps that take part in "dynamic resolution": that ask it, from moment to
moment, at what size it wants their picture. The app asks (and up to 0.6 also showed the
picture at that size, see "The picture"); `dynamic_resolution=0` turns the asking off. Whether
the system grants the higher level shows in what `tools/quest-recorder.sh` keeps of a session
(`GPU=4/5`, `.../599MHz` in the `VrApi` lines, `Dynres boost enabled` among the reasons for the
GPU's maximum). In the second and third sessions it did: level 5, 599 MHz throughout.
The last level, 640 MHz, the system only gives to an app that also trades a processor level for
it, which is a line in the app's manifest and so a build of its own:
`quest-host/build.sh --trade-cpu-for-gpu`. The processor then tops out at 1.65 instead of
1.92 GHz, 14% less for 7% more GPU. In the second session the GPU was the one that ran out,
with the emulator using two of its three cores; but the thread that hands the game's drawing to
the GPU is one of the two, and it is the one a slower processor would hold up. Not tried in a
session; the default build does not trade.
The app also asks for the processor's "boost" level, which the system grants for the first 45
seconds and up to a fifth of the time after that (2.36 GHz): that is when the emulator
translates most of the game's code. `cpu_boost=0` turns it off.
Tried and dropped: FEX without x86 memory ordering (`SHADPS4_FEX_TSO=0`) - the game stops within
half a minute. The driver without its lowering of uniform buffers to constants
(`IR3_SHADER_DEBUG=nouboopt`) - the same GPU time. Not setting vertex layout, vertex and index
buffers and push constants again when they are what the command buffer already has (half of
those calls) - no difference to the GPU or to the thread that makes them.
The app has the core log, every five seconds, the frame rate, what a frame consists of and how
long it takes on each part of its way ("frame path", `SHADPS4_FRAME_STATS=1`). The tools under
"Testing on the headset" show where GPU and processor time go.
### The headset's own performance features
What Meta offers apps for speed, and what each can do for a game that is drawn by an emulator
(checked against Meta's documentation; the pages are in `build/docs/`):
- **Application SpaceWarp** has the app draw at half the display rate and the compositor make up
every other frame. For that the app hands over, with every frame, a buffer that says for each
pixel how it moved since the frame before, and the depth buffer; whatever is drawn without
such motion vectors "appears to stutter". A game engine writes them in a pass of its own, from
each object's transform in this frame and the last. This game has no such pass (among its
render targets in the first level there is no velocity buffer), so the emulator would have to
work out, for each of the game's 1800 draws a frame, where the same draw put its vertices a
frame earlier, and draw all of it once more. That is a project of its own with an uncertain
end, and the extra pass would cost frame rate that is already short of the exact half rate
SpaceWarp insists on (60 for 120 Hz, 40 for 80 Hz, 36 for 72 Hz). Not done.
What the compositor does do with every frame, at the display's rate, is turn it to where the
head points by now. That is the same reprojection PlayStation VR used to show this game's 60
frames at 120 Hz.
- **Fixed foveated rendering** makes the GPU draw the edges of the image an app renders into at
a lower resolution. The image this app renders into only receives a copy of the finished
picture, which is no work to speak of. The picture itself is drawn by the emulator into the
game's own targets; there the driver could only do it in its tiled mode, which halves the
frame rate of this game (it is the geometry that is expensive, not the pixels, and the game
already draws at its lowest resolution, with the area the lenses cannot show masked out).
- **Super Resolution** is the better of the two filters the compositor can enlarge the picture
with. Left to the system (the default up to 0.6) it was off two thirds of the time in a
level; `sharpen=2` now asks for it unconditionally, the default is the emulator's own
sharpening (see "The picture").
- **GPU and processor levels**: see above.
## The picture
In the third session the game was up to speed and the view was right, and the picture "looked
way worse" than on the console. It did, for four reasons, of which the size of the scene was
only one:
- **No anti-aliasing.** The console draws this game with multisampling (four colour samples
and eight depth samples a pixel). On the Quest that is switched off (`msaa=1`): two samples
cost 9 to 28% of a frame's time, four 28 to 40%, which is more than there is. Every edge was
therefore as jagged as its pixels, and the headset's display, which has about two and a half
pixels across for each of the picture's, shows every step. From 0.7 on the emulator smooths
edges in the finished scene instead: where the game "resolves" a multisampled picture that
the emulator holds with one sample a pixel, there is nothing to average, and the copy that
used to stand in for it is now a filter that finds edges by their contrast and blends each
pixel on one with its neighbour across it, by how far along the edge it sits (FXAA; it works
on the scene before tone mapping, so a glaring pixel does not decide everything around it;
`src/video_core/host_shaders/resolve_aa.frag`). It costs about 3% of a frame's time (1 ms at
816x870) and is not tied to this game. `antialias=0` turns it off.
- **The size**, see above: 816x870 for half of the level.
- **Shown smaller.** Up to 0.6 the app showed the picture at the size the system recommended
from moment to moment, 15% smaller in each direction whenever the GPU was busy, which in a
level is always. That saved nothing worth having (the picture is copied once per frame of
the game) and blurred what the game drew. Now the app still asks for the recommendation,
which is what the system wants of an app before it lets the GPU run at its faster clocks,
and shows the picture at full size. Whether the system still grants the faster clock (599
MHz, "level 5") that way is the first thing to look for after the next session: `GPU=4/5`
and `599MHz` in the `VrApi` lines that `tools/quest-recorder.sh` keeps.
`dynamic_resolution=2` goes back to showing the picture at the recommended size.
- **No sharpening.** The headset was asked for its Super Resolution filter "when the system
finds the GPU has time", and the system found that in a third of the level. Now the emulator
sharpens each picture itself as it copies it to where the app takes it from (contrast
adaptive sharpening; `post_process.frag`), which costs too little to measure and happens
once per frame of the game instead of at every refresh. `sharpen=2` asks for Super
Resolution on every refresh instead, whatever the GPU is doing (it takes about 0.7 ms of
every refresh, a twentieth of the GPU at 90 Hz); `sharpen=0` leaves the picture soft.
`build/quest/picture-compare.png` shows three parts of the first level's picture as 0.6 showed
them, as 0.7 does in the heaviest views and in ordinary ones, and as a PlayStation 4 draws
them at its smallest and largest size (made from full-size dumps of the PC build by
`tools/picture-compare.py`).
How this compares with the console: a base PlayStation 4 shows this game at 60 frames a second,
960x1080 to 1440x1536, multisampled. The Quest 3 running it through an emulator shows the
levels at 30 frames a second, each frame for the same time, at 960x1080 to 1200x1280 in most
views and 816x870 in the heaviest, with edges smoothed by a filter instead of by multisampling;
lighter scenes at 45 frames a second and up to 1440x1536. Half the console's frame rate is what
the headset's GPU has in it for this game (a frame of the first level is 4 to 8 million
vertices in 750 to 2500 draws, 20 to 30 ms of GPU time at the smallest size); the picture is
now close to the console's at its smaller sizes.
**One surface as two targets of a draw.** The glow of the lava around the octopus of world 2
had squares in it, lit and dark, for as long as he moved. The game draws the rings that glow
into a quarter-size buffer which it has bound twice for those draws: as target 0, where the
colour is blended in, and as target 1, where alpha is written without blending. The console's
GPU writes both to the same memory. Vulkan leaves undefined what an image holds that was two
attachments of one render pass, and what it held was the draw in some blocks of 4x8 texels
and not in others. The emulator now makes such a draw once for every target that shares a
surface, with the others left out (`Rasterizer::SharedTargetPasses`). A scripted run gets to
the same octopus through the challenge "10 - Second Date" (with a save that has world 2
done): in the world select the challenge belt is chosen by looking 90 degrees to the right,
the challenge is three steps of the stick to the right, and the gadget chest at its start
takes the controller about 0.3 m below and 0.55 m ahead of the head;
`tools/pc-dump-test.sh` then keeps every render target of a frame of the fight.
## Sound
The game does all its sound through Sony's Ngs2 library, which the emulator only had as an empty
stub. `src/core/libraries/ngs2/ngs2_engine.cpp` implements what the game uses natively: sampler
voices (ATRAC9, HE-VAG, PCM), submixers, reverb, chorus/delay/pitch racks, mastering, and the
game's own effect callbacks. The game's 7.1 mix is rendered for the headset's ear speakers by a
virtual surround stage (`audio/surround_virtualizer.cpp`); `env=SHADPS4_VIRTUAL_SURROUND=0`
replaces it by a plain fold-down. The second mix the game produces for the television is not played.
The app plays each port on its own audio stream. The stream's buffer (60 ms) is what stands
between a game that is late for a moment and a gap in the sound: the emulator delivers as fast
as the stream takes it, and after a delay hands over what it missed at once, which fills the
buffer again. A stream that takes nothing at all (output suspended or rerouted) is not waited
for: its sound is dropped until it plays again, so the game never waits for sound nobody hears.
`host.log` has a line per stream every ten seconds: how much sound was delivered, dropped and
played, and how often the buffer ran dry. (In the second session the stream of the game's 3D
sound ran dry some 130 times in the ten seconds the first level took to load, and hardly ever
otherwise: the thread the game mixes it on, and the emulator's threads that pass sound on, had
no more claim to a processor than the game's helpers, which are busiest while a level loads.
They are now among the threads `SHADPS4_THREAD_NICE` puts first.)
(In the first session there was no sound at all: Android only starts to play a stream once its
buffer has been filled completely, and the app held back everything beyond a "cushion" that was
smaller than that.)
To check sound without listening: `SHADPS4_AUDIOOUT_DUMP=<seconds>` writes what every port plays
to WAV files in the emulator's user folder (`tools/wavstat.py <file> --png out.png` summarises one);
`SHADPS4_AUDIOOUT_STATS=1` logs every ten seconds how much of real time each port played and what
held it up.
## Microphone
The game listens to the microphone PlayStation VR has in its visor: blowing at things is one of
the ways to play with its world. The app passes the headset's own microphone on for that, and
asks for the permission the first time it starts; without it (or with `mic=0`) the game hears
silence, as it did before.
All the game does with the sound is measure how loud it is: 256 samples at a time, and from
their root mean square `rms` (1 being full scale) a strength of blowing
`min(1, 0.5 * ln(1 + 3.5 * 20 * log10(1 + rms / 1.5)))`. That is 1 from -9 dB on, a half at
-21 dB and a quarter at -30 dB. `host.log` says every ten seconds how loud the loudest moment was
on that scale, which is what to set `mic_gain` by should blowing do too little (or talking too
much). In an input script `mic=0.35` blows for as long as the line lasts, and
`SHADPS4_AUDIOIN_STATS=1` makes the emulator log what the game's port heard.
## Other firmware libraries
`src/core/libraries/json/json.cpp` implements the firmware's JSON library (libSceJson2), which the
emulator only stubbed. The game hands it the animation sequences of characters and sets of option
flags; with the stub every such text read as empty. `tools/tests/json_test.cpp` exercises it the
way the game calls it, and `SHADPS4_JSON_TRACE=<count>` logs what is parsed.
## PC test build
`tools/run-win.sh <seconds>` runs the game in the Windows build with a simulated headset. Useful
environment variables: `SHADPS4_VR_DEMO=1` (sweeps the head), `SHADPS4_INPUT_SCRIPT=<file>` (scripted
buttons, sticks, touchpad, head, controller and hand positions, a reset of the view, see
`shadps4-arm64-main/src/input/scripted_input.h`; examples in `build/dev/`). `tools/contact.mjs`
turns the automatic screenshots into a contact sheet.
`tools/pc-view-test.sh <name> <seconds> <"x,y,z"> [input script]` runs it with the head resting
at a given place and keeps pictures and log in `build/dev/view/<name>/`. For telling what the
game does with time and with the head, on a machine that is fast enough for anything:
| Setting | Result |
| --- | --- |
| `SHADPS4_DBG_FRAME_DELAY_MS=<ms>` | holds the game up by that much every frame (22 gives 43 frames a second, 32 gives 30) |
| `SHADPS4_SHOT_SECONDS=<n>[,<from>]` | a picture every n seconds by the clock, so that runs at different frame rates can be compared moment by moment |
| `SHADPS4_FRAME_STATS=1 SHADPS4_FRAME_STATS_EVERY=1` | frame rate and draws per frame every second: what the game shows over time, as a fingerprint of where it is |
| `SHADPS4_VR_REFRESH_RATE=<Hz>` | how often the emulated headset refreshes (60 to 120); the game draws half as many frames |
| `SHADPS4_VR_PACE=<2..6>` | how many of those refreshes every frame is given: 3 at 90 Hz makes 30 frames a second on the dot (without it the emulator chooses, which on a PC is always 2) |
| `SHADPS4_TITLE_TIMESTEP=0`, `SHADPS4_TITLE_RESOLUTION=<3..6 or title>` | the game's time step left alone; its scene held to one size (3 = 816x870 ... 6 = 1440x1536) or left to the game |
| `SHADPS4_TITLE_PHYSICS_STEP=0`, `SHADPS4_TITLE_PHYSICS_WATCH=1` | the game's physics step left as the console has it (collisions that are moved then land off, see "Speed"); a line in the log every ten seconds on how far the bodies the game sent somewhere ended from there |
| `SHADPS4_MAX_MSAA=<1, 2, 4>` | the most samples a pixel gets; 1 is what the headset runs with, and has the emulator smooth edges itself unless `SHADPS4_RESOLVE_AA=0` |
| `SHADPS4_VR_SHARPEN=<0..1>` | how much the eyes' pictures are sharpened on their way out (the app sets 0.6) |
`tools/pc-dump-test.sh <name> <seconds> [NAME=value ...]` plays into the first level and keeps
everything the game has drawn at that moment, at full size, in `build/dev/dump/<name>/` (the
eyes' finished pictures are the `..._523ce0000_1440x1536_...` and `..._5245e0000_...` files,
the scene before the game enlarges it the `960x1080_R16G16B16A16Sfloat` ones, or whatever size
it was held to). `tools/crop.py` cuts the same part out of several of them and puts the cuts
side by side, `tools/picture-compare.py` makes the comparison in `build/quest/picture-compare.png`,
and `tools/aa_proto.py` is where edge smoothing, enlarging and sharpening were tried out on
such dumps before any of it was written for the GPU.
`build/dev/input-gametime.txt` and `input-playcheck.txt` are the scripts the time step was
checked with, `input-origin2.txt` the one that shows when the game takes stock of the head.
## Testing on the headset without wearing it
`tools/quest-sandbox-test.sh <seconds> [input script]` runs the core inside the app's sandbox with
scripted input and pulls log, screenshots and audio dumps to `build/quest/run/`;
`tools/quest-selftest.sh <seconds>` exercises the app's own launch, socket and frame-sharing code.
Neither needs the headset to be unlocked; only the immersive session itself does.
Settings of `quest-sandbox-test.sh` for looking into speed (all described at the top of the script):
| Setting | Result |
| --- | --- |
| `SHADPS4_FRAME_STATS=1` (as an argument) | frame rate, passes, draws and the frame path in `run/core.log`; `=2` also lists the render passes of a frame and which code ended each |
| always | `run/gpu.txt`: GPU load, GPU and processor clocks every five seconds |
| `ASTRO_GPUTRACE_AT=<s>` | the driver's own trace from that second on; `tools/gputrace.py run/gputrace.txt <fps>` sums it up per kind of render pass |
| `ASTRO_THREADS_AT=<s> ASTRO_THREAD_SAMPLES="1500 shadPS4:GpuComm"` | a sampling profile of one thread; `node tools/symbolize-stacks.mjs --thread shadPS4:GpuComm --profile` prints it |
| `ASTRO_AT="<s>:<command>;..."` | shell commands at given times, e.g. to switch driver options through `TU_DEBUG_FILE` |
The sandbox is not a VR session: the core may only use three processor cores there, and the GPU
clock follows the load instead of being held by the VR runtime.
Settings of `quest-selftest.sh` that make it more like one (given like the core's, as
`NAME=value` arguments):
| Setting | Result |
| --- | --- |
| `HOST_COMPOSITOR=<Hz>[,<n>[,<Mpx>]]` | a stand-in for the compositor: so many times a second a pass the size of the compositor's own (9 megapixels) and n - 1 small ones (n = 5), on a GPU context that comes before the emulator's. With it the GPU is the limit as in a session |
| `HOST_DISPLAY=<Hz>` | the test's loop goes round once per refresh of a display of that rate, tells the emulator of each as the app does, and counts for how many refreshes each frame of the game stayed the newest |
| `HOST_LOOK="<s>:<left>,<up>;..."` | where the head looks from which second on (degrees); `8:85,22;110:0,-12` finds the first level from the world map and then looks along it |
| `HOST_PAD="<s>:<kind>,<right>,<up>,<ahead>,<left>,<tilt>,<roll>;..."` | what the app says of the controller from which second on, the way a session does: `c` one of the headset's own controllers (it says everything of itself), `h` hands around a gamepad without motion sensors, `p` hands around one that has them (only where it is), `n` nothing seen; where it is in metres from the head, how it is turned in degrees. With `HOST_LOOK=0:0,-18` the controller is in the picture |
| `HOST_TURN="<s>:<steps>;..."` | the view is turned by so many steps to the right at that second (to the left if negative), as the left grip held and the right stick flicked ask in a session. With `HOST_LOOK=0:0,0` the pictures before and after show the same scene from two sides |
| `HOST_SHOTS=<s>` | a picture every so many seconds instead of every ten |
| `XDG_DATA_HOME=<folder>` | where the emulator keeps its data, saves among them. The self-test starts from nothing each time, so the game plays its prologue (four minutes); with a folder that holds a save it is in the first level after 75 seconds |
| `SHADPS4_VR_FOLLOW_DISPLAY=0` | the emulated headset keeps to its own clock although the host tells of its display's refreshes |
`quest-selftest.sh` gives the game's microphone a test signal (a second of noise every five);
`ASTRO_MIC=real` opens the headset's own instead, which stays silent while nobody wears it.
`ASTRO_FRESH=1` makes it unpack the runtime from the installed app first, as the app does on its
first start, instead of testing the core in `build/arm64`. (Both test scripts otherwise put that
build into the app's runtime folder and remove the folder's stamp, so that the app unpacks its
own again the next time it is started.)
`tools/quest-level-bench.sh <name> <seconds>` is the self-test set up for measuring: a display
and a compositor stand-in of 90 Hz (`BENCH_HZ=72` or `120` for others), a save that has the game
in the first level after 75 seconds (the folder `bench-data` in the app's files, a copy of the
sandbox tests' save), the head looking at the level sideways until second 110 and down its
whole length after that, which is its heaviest view. It keeps report, logs and the GPU's clock
in `build/quest/bench/<name>.*` and prints what `tools/quest-bench-read.sh <name>` prints:
frames a second by ten seconds, for how many refreshes frames were shown, every decision on
pace and size, and every ten seconds what frames took at what size with the GPU how busy.
(`tools/quest-gpu-watch.sh <seconds> <file>` on its own writes GPU load, clock and temperature
once a second.) The self-test does not take one of its own settings twice: give `HOST_DISPLAY`
and `HOST_COMPOSITOR` once.
`tools/quest-xrprobe.sh` goes as far into OpenXR as the runtime lets an app go with nobody
wearing the headset: it checks that the app finds the runtime and that the extensions the host
uses are there, and then runs the host itself for five seconds - session, swapchains, hand
trackers and the frame buffers shared with the emulator are created as in a real start, the
session (which stays idle) is asked for the refresh rates on offer, the performance levels and
the thread hints, and a test picture is copied into the swapchain's images and read back. On
Horizon OS 207 all of that succeeds (72, 80, 90 and 120 Hz on offer). What it cannot reach is the
session itself: frames on the display, head and hand tracking, the controller.
`tools/quest-drystart.sh <seconds> [settings file]` starts the app's own activity without showing
it: everything the app does when it is launched runs (settings, log files, OpenXR up to the idle
session, unpacking the runtime, the emulator with the game, sound), a controller that is not
there presses ✕ now and then (through the same code a real one's events go through), pulls
its right stick back once and presses R1 once, and the `host.log` and `core.log` the app
writes are pulled to `build/quest/drystart/` together with pictures of what its status panel
showed, of the start-up menu and of the controls cards.
`SHADPS4_PAD_TOUCH_TRACE=1` (as `env=` in a settings file for the dry start, or as a setting of
`quest-sandbox-test.sh`, whose input scripts can press the gesture buttons with
`gesture=press`, `gesture=swipe` and `gesture=pull` and move the stick's finger with
`finger=x,y`) has core.log say what the game was given of the touchpad at each of its reads,
for as long as a finger was on it: where the game saw a touch begin and end, and over how
many reads, is what it makes its swipes and pulls of.
`quest-host/build.sh --test-package` builds the same app under another name
(`com.astrobotquest.vrhost.test`, "Astro VR Host (test)", `build/quest/astro-vr-host-test.apk`).
It installs next to the app proper and touches neither it nor its saves: for trying a build on
a headset whose owner plays with the other one. `ASTRO_PACKAGE=com.astrobotquest.vrhost.test`
before `quest-xrprobe.sh`, `quest-drystart.sh`, `quest-selftest.sh` or `quest-sandbox-test.sh`
runs them in it; `adb uninstall com.astrobotquest.vrhost.test` removes it again.
## Watching a real session
A session is played without a cable, so what the app cannot see itself is written down on the
headset: `tools/quest-recorder.sh start` leaves a small recorder running there (until the
headset is restarted; starting it again is harmless). Whenever the emulator runs it keeps, in
`/data/local/tmp/astro/sessions/<start time>/`, the VR runtime's own statistics (`VrApi` lines
once a second: frames shown and stale, processor and GPU level and clock, GPU time of the app
and of the compositor; `crcs` lines: every change of clock level and the reason for it), GPU
load, clock and temperature every two seconds, and the emulator's busiest threads with the cores
they ran on every thirty.
`tools/quest-recorder.sh pull`, with the headset back on the cable, fetches all of it to
`build/quest/sessions/`, with the app's `host.log` and `core.log` next to the newest session.
`status` says whether the recorder runs and what it has.
What to read first after a session with 0.7: in `core.log`, "Frames are given ... refreshes"
and "The scene is drawn at" (how often pace and size changed, and between what), and the
ten-second lines "The title's clock" (frames at 33.3 ms with the GPU under 90% busy is the level
running as intended; frames longer than they are given with the GPU at 100% is the GPU short).
In the recorder's `VrApi` lines, `GPU=` and the clock after it (level 5, 599 MHz, is what the
app had so far while it showed the picture at the recommended size; it now only asks for that
size), `FPS=` against the display's 90, and `Stale`. In `host.log`, the "controller:" lines
(vendor 1356 is Sony) and "picture shown at" (1440x1536, and what the system recommended).
(`tools/quest-session-watch.sh` does the same from the PC, for a headset that stays connected.)
Should the game stop drawing, the app's panel says so after twenty seconds ("No new picture
from the game for ... seconds") instead of "Loading...", and the emulator writes what each of
its threads was doing into `core.log`; `node tools/symbolize-stacks.mjs <core.log>` turns that
into function names. The one such stop met so far is described in
`shadps4-arm64-main/src/video_core/renderer_vulkan/vk_master_semaphore.cpp`.
+139 -282
View File
@@ -1,305 +1,165 @@
# AstroQuest
# AstroQuest-Frame
## Steam Frame fork
Play **ASTRO BOT Rescue Mission** on **Steam Frame** with an experimental native
ARM64 Linux build of AstroQuest. The emulator runs on the headset, using FEXCore
for the PS4 game's CPU instructions, Vulkan for rendering, and SteamVR OpenXR for
stereo VR and tracking.
This fork adds an experimental native ARM64 Linux build for standalone Steam Frame,
with Vulkan rendering, SteamVR OpenXR and native Frame controller bindings.
[Download the Steam Frame release](https://github.com/mitch030504/AstroQuest/releases/tag/steam-frame-v0.1.0)
and follow [the install and build guide](README-FRAME.md).
**[Download v0.2.0](https://github.com/mitch030504/AstroQuest-Frame/releases/tag/steam-frame-v0.2.0)**
· [Detailed setup and build guide](README-FRAME.md)
· [Report an issue](https://github.com/mitch030504/AstroQuest-Frame/issues)
Based on [evertec82/AstroQuest](https://github.com/evertec82/AstroQuest), which builds on
[bigmak94/AstroQuest](https://github.com/bigmak94/AstroQuest). The upstream PC and Quest
instructions and acknowledgements are retained below. Full gameplay on Frame is still
unverified.
This fork includes experimental PC VR performance changes: a separate Vulkan queue for
OpenXR, configurable recovery after a drop to half refresh, Windows CPU timing diagnostics,
and a GUI launcher for resolution and maximum framerate. Start `Play AstroQuest.bat` after
building the Windows emulator into `pc-vr/shadps4.exe`. See the fork notes in
[README-PC-VR.md](README-PC-VR.md#experimental-pc-performance-build).
**ASTRO BOT Rescue Mission (PS4 / PlayStation VR) in virtual reality on Meta Quest 3**, played
from your own copy of the game through a PS4 emulator. Ways to play:
- **On the headset alone**: an app for the Quest 3 runs the emulator on the headset itself. No
PC is needed once it is installed. Please note: while the standalone Meta Quest 3 build is
fully functional and playable, the headset’s mobile GPU is pushed to its limits. As a result,
native standalone visuals run at lower resolutions and visual fidelity compared to the original
PS4 hardware or the PC VR mode.
- **On a Windows PC, shown in the Quest through Virtual Desktop**: the PC runs the game at the
console's 60 frames a second and at up to six times its resolution.
- **On a Windows PC with a Valve Index through SteamVR**: the same PC build uses SteamVR's
OpenXR runtime and a DualSense connected to the PC. Headset playback has been reported working; see
[the Index setup](README-PC-VR.md#valve-index-through-steamvr), including controller
positional-tracking limits. Other PC headsets go the same way, through SteamVR or an OpenXR
runtime of their own: a Bigscreen Beyond and a Pimax Dream Air have been reported working.
The emulator is [shadPS4](https://github.com/shadps4-emu/shadPS4) (its ARM64 build,
[zenithblue-oss/shadps4-arm64](https://github.com/zenithblue-oss/shadps4-arm64), on the
headset), to which this project adds what the game needs from a PlayStation VR: the headset,
its tracking and reprojection, the camera's view of the controller (taken from the Quest's
hand tracking), the 3D sound engine, the microphone, and fixes for the game's timing and
resolution under emulation.
> **No game files are included or distributed.** You need your own copy of ASTRO BOT Rescue
> Mission, dumped from your own PlayStation 4.
| | PlayStation 4 + PS VR | Quest 3 on its own | PC + Virtual Desktop |
| --- | --- | --- | --- |
| Frames a second | 60 | 30 in levels, 45 in lighter scenes | 60 (more if you ask for it) |
| Picture per eye | 960x1080 to 1440x1536 | 816x870 to 1440x1536 | 1440x1536 up to 3600x3840 |
| Edges | multisampled | smoothed by a filter | multisampled |
> Supply your own dumped game. No game files, PS4 firmware or keys are included.
## Status
Work in progress, built and tested by one person on one Quest 3 and one PC. World 1 & 2has been
played through on the Quest 3 on its own; the PC path has been played
over Virtual Desktop and fixed since. Expect rough edges, and please report what you find.
This is a beta release. Opening gameplay, stereo rendering and head
and controller tracking have been observed on Steam Frame with SteamVR/OpenXR
2.18.2. A full playthrough has not been completed.
| Feature | Current status |
| --- | --- |
| Native ARM64 build and stereo VR launch | Checked on Steam Frame |
| Head and right-controller tracking | Observed in the opening scene |
| A/B/X/Y buttons | Native Frame bindings accepted by SteamVR; latest Circle action awaits player confirmation |
| D-pad | Added in v0.1.1; gameplay behavior awaits player confirmation |
| Touchpad gadgets, microphone and separate gamepads | Implemented paths; unverified on Frame |
## What you need
- **A Meta Quest 3, or a Valve Index connected to a Windows PC running SteamVR.**
(The Quest 3S has the same chip and should work, but nobody has tried.)
- **Something to play with.** A PS5 DualSense controller is best: it stands in for the PS4
controller the game expects with everything that one has (buttons, sticks, touchpad, motion
sensors, rumble and light bar; a DualShock 4 should do the same, untested). Without one,
**the Quest's own Touch controllers play the game**, on the headset alone as on the PC.
Other gamepads play too (Xbox, 8BitDo and the like): what they lack of a PS4 controller,
the touchpad and the motion sensors, is made up for, see "Playing" below.
- **ASTRO BOT Rescue Mission, European release CUSA12392, version 1.00 or 1.04** (the game
as on its disc, or with its last update), dumped from your own console and game: either as
the game's folder (the one with `eboot.bin`, `sce_sys`, `sce_module` in it, about 13 GB) or
as the `.pkg` package made from the dump, which the PC launcher unpacks by itself. (A
package downloaded from the PlayStation Store is encrypted and cannot be used; a package
that is only the game's update is not the game: unpacked into a folder named
`CUSA12392-UPDATE` next to the game's `CUSA12392`, it is played over it.) Other regions and
the versions in between are untested, and the fixes for the game's timing and resolution
only apply to these two.
- To install on the headset: a computer with
[adb](https://developer.android.com/tools/releases/platform-tools) (or
[SideQuest](https://sidequestvr.com/)) and the headset in
[developer mode](https://developers.meta.com/horizon/documentation/native/android/mobile-device-setup/#enable-developer-mode)
(set in the Meta Horizon app on your phone; on Windows, adb may also need Meta's ADB
driver, described on the same page).
- To play on the PC: Windows 10 or 11 (64-bit), a graphics card with Vulkan 1.3, 16 GB of
memory or more (the emulator asks Windows for about 14 GB), the
[Microsoft Visual C++ Redistributable (x64)](https://aka.ms/vs/17/release/vc_redist.x64.exe)
(the launcher says so if it is missing), and [Virtual Desktop](https://www.vrdesktop.net/)
(the app on the Quest, the Streamer on the PC).
- A Steam Frame running SteamOS and its built-in SteamVR/OpenXR runtime.
- **Steam Linux Runtime 4.0 - Arm64**, installed on the Frame through Steam. The launcher uses
its installed runtime; the release does not bundle it.
- SteamVR support for `XR_VALVE_frame_controller_interaction`. The device used
for this port runs SteamVR/OpenXR 2.18.2.
- Your own extracted **ASTRO BOT Rescue Mission** dump, including `eboot.bin`
and `sce_sys/param.sfo`, plus space for the game and its optional update.
## Installing: Quest 3 on its own
The Frame launch was checked with the **US release CUSA12431**. Both US 1.00 and
1.04 executables match the guarded signatures used by the existing timing,
resolution and physics fixes. European **CUSA12392** 1.00/1.04 support is inherited
from AstroQuest; it has not been checked on Frame. Other builds are unverified.
Download `AstroQuest-<version>-Quest3.apk` from the
[latest release](https://github.com/bigmak94/AstroQuest/releases/latest), then:
## Install and launch
1. **Install the app.** Connect the headset to the computer with a USB cable, allow USB
debugging in the headset, and run
Run the installer **on the Frame**, as your normal Steam user. For SSH access,
see [Valve's developer-mode setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup#developer-mode).
1. Download `AstroQuest-SteamFrame-ARM64.tar.gz` and `SHA256SUMS.txt` from
[v0.2.0](https://github.com/mitch030504/AstroQuest-Frame/releases/tag/steam-frame-v0.2.0)
and put both files in the same folder on the Frame.
2. In that folder, run:
```sh
adb install -r AstroQuest-0.20-Quest3.apk
sha256sum -c SHA256SUMS.txt && \
tar -xzf AstroQuest-SteamFrame-ARM64.tar.gz && \
bash astroquest-frame/install.sh
```
(or drag the APK onto SideQuest).
2. **Copy the game to the headset**, the folder named `CUSA12392` (this takes a few minutes):
The installer copies the app to `~/Applications/astroquest-frame` and registers
its Steam shortcut through the Frame's devkit interface. If the ARM64 runtime
is missing, it opens Steam's install dialog; finish that installation in the
headset, then rerun `install.sh`.
3. Put your extracted US base game in `~/Games/AstroQuest/CUSA12431`. Optional
update 1.04 goes in the sibling `CUSA12431-UPDATE` folder. Both folders need
their own `sce_sys/param.sfo`. See
[game preparation](README-FRAME.md#prepare-the-game) for extraction details.
4. In the headset, open **Library > Non-Steam > Devkit Game: AstroQuest_Frame**.
Close the Steam dashboard to give the game focus.
```sh
adb push CUSA12392 /data/local/tmp/astro/games/
```
The app also finds it in `/sdcard/Android/data/com.astrobotquest.vrhost/files/games/CUSA12392`,
but that folder is deleted with the app if it is ever uninstalled.
**If your game is a `.pkg` file**, unpack it on a PC first: the headset needs the game's
folder. Two ways:
- **The easy one, on Windows**: download `AstroQuest-<version>-PC-VR-Windows.zip` from the
same release, unzip it, put your `.pkg` in its `games` folder and start
`Play Astro Bot VR.bat`. It offers to unpack the package (about a minute, 13 GB); you can
close it after that. `games\CUSA12392` is then the folder to copy to the headset. You do
not need Virtual Desktop or a VR-ready PC for this.
- **By hand, with the tool it uses**: [PkgTool](https://github.com/maxton/LibOrbisPkg/releases/tag/v0.2)
(`PkgTool-0.2.231.zip`, free and open source; Windows, or the `PkgTool.Core` builds for
Linux and macOS). `PkgTool pkg_extract --passcode 00000000000000000000000000000000 game.pkg out`
puts the game in `out/uroot`: rename that folder `CUSA12392`. Then add the game's
description, which the package keeps apart: `PkgTool pkg_listentries game.pkg` shows the
number of `PARAM_SFO`, and `PkgTool pkg_extractentry game.pkg <number> CUSA12392/sce_sys/param.sfo`
writes it. Without `param.sfo` the emulator does not recognise the game.
Either way, only a package made from a dump of the game can be unpacked; one downloaded
from the PlayStation Store is encrypted.
3. **If you play with a gamepad, pair it with the headset**: Settings > Bluetooth > Pair (on
a DualSense, hold Create and the PS button until the light bar flashes). The Touch
controllers need nothing.
4. **Start "Astro VR Host"** from the App Library, under *Unknown Sources*. The first start
takes a few seconds longer (it unpacks the emulator) and asks for the microphone: the game
listens to it for blowing at things, as it did with PlayStation VR's.
Playing:
- The app begins with a menu for the field of view: a stick to a side chooses, ✕ / A or a
trigger starts the game.
- **With a gamepad**: put the Touch controllers aside and hold it. The headset tracks your
hands around it, and that is where the controller is in the game. A gamepad without motion
sensors, which is every one that is not a PlayStation's, is turned and tilted in the game
by your hands, as far as the headset sees them.
- **With the Touch controllers**: the right one is the controller in the game, where it is
and the way it points. Left stick to move; A is ✕, B is □, X is ○, Y is △; the left
controller's menu button is OPTIONS. X and Y together blow, where the game wants you to
blow into the microphone.
- **The touchpad, on the Touch controllers and on a gamepad that has none**, is on buttons
(a card in the headset says so when the game starts, and again whenever you press
OPTIONS):
- **right trigger (R2): press it and hold**: the water cannon, the machine gun
- **right grip (R1): swipe forward**: the hook, the throwing stars, the chests
- **left trigger (L2): pull back, let go to shoot**: the catapult at the end of every
level (point the controller at the goal)
- the right stick is a finger on it, for anything else.
- With both at hand, the game is played with the one you used last: a button on the other
changes over.
- **To turn round without turning yourself** (on a seat that does not turn): hold the left
grip, or L1 on a gamepad, and flick the right stick to a side. Each flick turns the view 30
degrees that way; resetting the view faces you straight ahead again.
- **The game speaks the headset's language**, of those it has (28; English otherwise).
- The first screen asks to move the controller into a floating outline: hold it up in front
of you where the outline is.
- On the world map, **look** at a planet and press ✕.
- **Reset the view** by holding OPTIONS for a second (or pressing the PS button; with the
Touch controllers, hold the left menu button for a second or press both sticks in)
whenever you sit differently and things are too close, too far or off to one side.
- Taking the headset off pauses the game.
- Settings (refresh rate, resolution, field of view, sharpening, microphone gain...) go in
`/sdcard/Android/data/com.astrobotquest.vrhost/files/vrhost.txt`; all of them are explained in
[README-QUEST-VR.md](README-QUEST-VR.md).
Your saves are kept in `/sdcard/Android/data/com.astrobotquest.vrhost/files/data/shadPS4/home/1000/savedata`.
Updating the app with `adb install -r` keeps them; **uninstalling deletes them**, so copy that
folder off the headset first (`adb pull`).
## Installing: PC VR through Virtual Desktop
Download `AstroQuest-<version>-PC-VR-Windows.zip` from the
[latest release](https://github.com/bigmak94/AstroQuest/releases/latest), then:
1. **Unzip it** into a folder with a short path, e.g. `C:\Games\AstroQuest`. Some of the
game's files have long names, and the emulator cannot open a file whose full path is
longer than Windows' 260 characters: keep the path of the folder that holds `games` under
about 110 characters.
2. **Put your copy of the game in its `games` folder**, anywhere in it: the game's folder
(the one with `eboot.bin` in it) or its `.pkg` file. A package is unpacked the first time
you start, which takes a minute or so and about 13 GB. You can also leave the game where
it is: when the launcher finds none, a window asks where it is and remembers the answer.
3. **Set up Virtual Desktop**: install the Streamer on the PC and, in its Options, choose
**VDXR** as the OpenXR runtime. In the headset, in Virtual Desktop's Streaming settings,
set the frame rate to **120** (the game then runs at 60 frames a second, as on the console).
4. **Connect the DualSense to the PC**, by USB cable or by Bluetooth paired with the PC, not
with the headset: paired with the headset, it reaches the PC without motion sensors or
touchpad. The Touch controllers play as well: with no gamepad on the PC, and with one
whenever they were the ones used last.
5. **Connect to the PC with Virtual Desktop**, then start **`Play Astro Bot VR.bat`** on the
desktop you see in the headset. The first time, it offers to unpack the game if it is a
package (or asks where the game is). Then a small window lets you choose each eye's resolution, the
frame rate, the game's language (Windows' own unless you choose another) and the field of
view; Play starts the game, and the headset switches to it after a few seconds.
For the controller to be placed by your hands, turn on hand tracking in the headset and let
Virtual Desktop forward tracking data to the PC. The Touch controller layout, all settings and
what to do when something does not work are in [README-PC-VR.md](README-PC-VR.md).
## Common questions
**How do I get the game onto my computer?** This project gives no information on how to get
games, and this repository does not include any game data.
**Which headsets?** On the headset alone: Quest 3 (the Quest 3S has the same chip and should
work; nobody has reported it yet). A Quest 2 or Pro has not been tried and is slower. On a
PC: any headset with an OpenXR runtime on Windows, which is every Quest through Virtual
Desktop or Steam Link, and PC headsets through SteamVR (Index, PS VR2 with its PC adapter,
Bigscreen Beyond, Pimax and others have been reported working).
**Do I need a PlayStation controller?** No. The headset's own controllers play, on the
headset alone and on the PC, and so do other gamepads; see "Playing". A DualSense is still
the closest to what the game was made for.
**Without Virtual Desktop?** On the PC, yes: with SteamVR as the OpenXR runtime
([README-PC-VR.md](README-PC-VR.md#valve-index-through-steamvr)). Start SteamVR first, then
`Play Astro Bot VR.bat`.
**The picture looks like it is "reprojected", whatever I set.** The game draws one frame
for every two refreshes of the headset, as it does on a PlayStation VR: 60 frames a second at
120 Hz, 45 at 90 Hz, 36 at 72 Hz, and your head's turning is filled in between. Set the
headset to 120 Hz for the console's own 60. On a fast PC, "Frames a second, at most" in the
launcher's window can be raised to the headset's rate.
**The game is in English.** From 0.19 on it takes the language of Windows or of the headset
(one of the game's 28; English otherwise); the PC launcher's window has a list to choose
another from.
**The launcher keeps asking for the Visual C++ runtime although it is installed.** Fixed in
0.19 (it looked in the wrong place when it was started from a 32-bit program), and its
question now has a "start the game all the same".
**Linux?** There is no Linux build of the PC version. A player reports it running through
Steam's Proton with WiVRn; their recipe is in
[README-PC-VR.md](README-PC-VR.md#linux-through-proton-as-reported).
## Building from source
Clone with the submodules:
For a game stored elsewhere, pass its extracted folder to the installer:
```sh
git clone --recurse-submodules https://github.com/bigmak94/AstroQuest.git
bash astroquest-frame/install.sh /path/to/CUSA12431
```
Everything builds on Windows from Git Bash, without admin rights, with a portable toolchain
kept in `tools/` (not in the repository): LLVM/clang 21 (`tools/llvm`), CMake 4.4
(`tools/cmake`), Ninja (`tools/ninja`) and the MSVC CRT and Windows SDK fetched with
[xwin](https://github.com/Jake-Shadle/xwin) (`tools/winsdk`).
That location is saved for the shortcut and retained when you rerun the installer
without an argument. Updating uses the same installer; quit and relaunch the game
to load the new build. This native Linux package uses Steam Linux Runtime 4 ARM64.
- **PC build**: `source tools/env-win.sh && cmake --build build/win-x64 --target shadps4`, then
`bash tools/make-pc-vr.sh` puts it in `pc-vr/`.
- **Quest app**: `bash tools/build-arm64.sh` cross-compiles the emulator for the headset
(Debian 13 arm64 sysroot made by `tools/mk-sysroot.mjs`, FEXCore built by the upstream
`shadps4-arm64-main/runtime/scripts/build-fexcore-smoke-aarch64.sh`), then
`bash quest-host/build.sh` builds the APK. That needs the Android SDK (build-tools 36.1,
platform 36), an NDK, a JDK, the
[Khronos OpenXR loader for Android 1.1.63](https://repo1.maven.org/maven2/org/khronos/openxr/openxr_loader_for_android/1.1.63/)
(the `.aar` unzipped into `tools/openxr`), and the
[Bachata S4 v0.2.4](https://github.com/JICA98/Bachata-S4/releases/tag/v0.2.4) release APK
(`bachatas4-0.2.4-release.apk`, SHA-256 `4077b0d0b80e354c71eff1ce2c003f122449ac266eb27a6ac26744d3f9809a08`)
in the top folder, whose Linux runtime (glibc, its libraries and the Turnip Vulkan driver)
the app carries.
- `bash tools/make-release.sh <version>` packs the release files into `build/release/`. It
puts [PkgTool 0.2.231](https://github.com/maxton/LibOrbisPkg/releases/tag/v0.2), unzipped
into `tools/pkgtool`, in the PC package: the launcher unpacks a game package with it.
The SteamOS Devkit Client can also upload the app and add its shortcut; see
[the alternative workflow](README-FRAME.md#alternative-steamos-devkit-client).
How it all works, and the tools used to test it on the headset and with a simulated one, is
described in [README-QUEST-VR.md](README-QUEST-VR.md) and [README-PC-VR.md](README-PC-VR.md).
## Frame controls
## Support the project
The native Frame profile keeps the four right-hand face buttons distinct:
If AstroQuest gave you a good time in VR and you want to help it go further, you can support
its development here:
| Frame input | PS4 input / action |
| --- | --- |
| Right A | Cross — jump / confirm |
| Right B | Circle |
| Right X | Square — attack |
| Right Y | Triangle |
| Left stick | Move |
| Left D-pad | PS4 D-pad |
| Right stick | Move a finger on the touchpad; press it to click the touchpad |
| Left View | Options |
| Both sticks pressed | Recenter |
| Left grip held + right stick flicked sideways | Turn the view in steps |
| B + Y held | Simulate blowing into the microphone |
[![Support AstroQuest](https://img.shields.io/badge/Support-AstroQuest-635BFF?style=for-the-badge&logo=stripe&logoColor=white)](https://buy.stripe.com/6oU4gB5450PZ18h8aK1wY00)
The right stick's touchpad input is used by gadgets and may have no visible
response in the opening scene. The **right controller** supplies the virtual
controller's position and orientation. Trigger and grip gestures are described
in [the full control guide](README-FRAME.md#controls).
**https://buy.stripe.com/6oU4gB5450PZ18h8aK1wY00**
SteamVR's automatic Touch-controller conversion mapped Frame B, X and Y to Square.
The native Frame profile fixes that conversion. The duplicate SDL gamepad from the
Frame controllers is filtered out during VR so it cannot compete with OpenXR input.
## License
## Updates, settings and troubleshooting
AstroQuest is free software, licensed under the
[GNU General Public License, version 2 or (at your option) any later version](LICENSE)
(GPL-2.0-or-later), the license of shadPS4 it is built on.
Extract a newer release and run its `install.sh`, then restart the game. Saves and configuration live separately in
`~/.local/share/astroquest/user`; replacing the application keeps them.
The third-party components it uses or ships keep their own licenses: among them FEX (MIT),
Mesa's Turnip Vulkan driver (MIT), the GNU C Library (LGPL-2.1-or-later), PkgTool (LGPL-3.0), the Khronos OpenXR
SDK (Apache-2.0) and the libraries under `shadps4-arm64-main/externals`. See
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for what the release files contain and where
the source of each part is.
The default scene resolution is **816×870 per eye**. Higher resolutions and
pacing controls are documented in [settings](README-FRAME.md#settings).
Eye-tracked foveation is experimental and opt-in (beta); it uses the 1200×1280
preset unless a resolution is set explicitly.
See the [foveation setting](README-FRAME.md#settings).
Performance depends on the scene and settings; this release has no guaranteed
frame rate.
ASTRO BOT, PlayStation and PlayStation VR are trademarks of Sony Interactive Entertainment.
This project is not affiliated with, endorsed or sponsored by Sony Interactive Entertainment,
Team Asobi or Meta. It contains no game, firmware, keys or other copyrighted console files:
use it only with software you own and have dumped yourself.
For a black screen or missing input, check the game folder, installed ARM64
runtime, the Frame's built-in VR runtime and dashboard focus. See
[troubleshooting](README-FRAME.md#troubleshooting) before reporting a problem.
Console output is in `~/.local/share/astroquest/logs/console.log`.
## Build from source
Run on Steam Frame or an ARM64 Linux host with Git and rootless Podman available:
```sh
git clone --recurse-submodules https://github.com/mitch030504/AstroQuest-Frame.git
cd AstroQuest-Frame
bash tools/build-frame.sh
```
The script builds in a dedicated container using Valve's ARM64 Steam Runtime 4
SDK pinned to `4.0.20260805.254769`. It produces
`build/AstroQuest-SteamFrame-ARM64.tar.gz`. See
[build details](README-FRAME.md#build-from-source) for the container and dependencies.
## Upstream
AstroQuest-Frame is based on
[evertec82/AstroQuest](https://github.com/evertec82/AstroQuest), which builds on
[bigmak94/AstroQuest](https://github.com/bigmak94/AstroQuest). This fork focuses on
the native Steam Frame ARM64 build; its releases contain the Frame package and checksum.
## License and attribution
AstroQuest-Frame is licensed under
[GPL-2.0-or-later](LICENSE), as is the shadPS4 emulator it builds on. Dependencies
retain their own licenses; the archive includes them under `licenses/`. See
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for sources and release contents.
ASTRO BOT, PlayStation and PlayStation VR are trademarks of Sony Interactive
Entertainment. This project is not affiliated with, endorsed or sponsored by Sony
Interactive Entertainment, Team Asobi, Meta or Valve.
To support the original AstroQuest project, use
[its upstream support link](https://buy.stripe.com/6oU4gB5450PZ18h8aK1wY00).
## Thanks
@@ -307,19 +167,16 @@ use it only with software you own and have dumped yourself.
this would exist without their PlayStation 4 emulator: everything here is built on top of
their years of work. Thank you!
- [zenithblue-oss/shadps4-arm64](https://github.com/zenithblue-oss/shadps4-arm64) and
[Bachata S4](https://github.com/JICA98/Bachata-S4), for the ARM64 build of shadPS4, its FEX
integration and the Linux runtime it runs in on Android.
[Bachata S4](https://github.com/JICA98/Bachata-S4), for the ARM64 build of shadPS4,
FEX integration and Linux runtime work used by this port.
- [FEX-Emu](https://github.com/FEX-Emu/FEX), which runs the game's x86-64 code on the
headset's ARM processor.
- [Mesa](https://www.mesa3d.org/) and its Turnip driver for Adreno GPUs (the build used here
comes from [Vauzi-17/mesa-tu8](https://github.com/Vauzi-17/mesa-tu8)).
- [LibOrbisPkg](https://github.com/maxton/LibOrbisPkg), whose PkgTool unpacks game packages
for the PC launcher.
- [Mesa](https://www.mesa3d.org/) and its Turnip driver for Adreno GPUs. Frame uses
the SteamOS driver.
- [LibOrbisPkg](https://github.com/maxton/LibOrbisPkg), whose PkgTool can unpack game packages.
- Everyone who reported what went wrong, and those who sent the fix along:
[ODevStudio](https://github.com/ODevStudio) for SteamVR and Valve Index support and the
desktop's spectator views, [Clodo76](https://github.com/Clodo76) for finding the game's
[Clodo76](https://github.com/Clodo76) for finding the game's
1.04 executable from inside, [evertec82](https://github.com/evertec82) for what stopped the
game under SteamVR.
- [The Khronos Group](https://www.khronos.org/) for OpenXR and Vulkan,
[vgmstream](https://github.com/vgmstream/vgmstream) for documenting Sony's audio formats,
and [Virtual Desktop](https://www.vrdesktop.net/) for the PC streaming path.
- [The Khronos Group](https://www.khronos.org/) for OpenXR and Vulkan, and
[vgmstream](https://github.com/vgmstream/vgmstream) for documenting Sony's audio formats.
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode FullRefresh
pause
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Original
pause
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Recovery
pause
-32
View File
@@ -1,32 +0,0 @@
param([ValidateSet('Play', 'Recovery', 'Original', 'FullRefresh')][string]$Mode = 'Play')
$ErrorActionPreference = 'Stop'
$pcRoot = Join-Path $PSScriptRoot 'pc-vr'
$archive = Join-Path $PSScriptRoot ('test-logs/' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '-' + $Mode)
New-Item -ItemType Directory -Force $archive | Out-Null
$env:SHADPS4_XR_SHARED_QUEUE = '0'
$env:SHADPS4_VR_RETRY_SECONDS = if ($Mode -eq 'Original') { '600' } else { '15' }
$env:SHADPS4_VR_PACE = if ($Mode -eq 'FullRefresh') { '1' } elseif ($Mode -eq 'Play') { '' } else { '0' }
$env:SHADPS4_FRAME_STATS = '1'
$env:SHADPS4_FRAME_STATS_EVERY = '5'
Copy-Item -LiteralPath (Join-Path $pcRoot 'settings.txt') -Destination $archive
@("Mode=$Mode", "Started=$(Get-Date -Format o)", "RetrySeconds=$env:SHADPS4_VR_RETRY_SECONDS",
"FixedPace=$env:SHADPS4_VR_PACE", "ExecutableSHA256=$((Get-FileHash (Join-Path $pcRoot 'shadps4.exe')).Hash)") |
Set-Content -LiteralPath (Join-Path $archive 'run-info.txt') -Encoding UTF8
Write-Host "Choose resolution and Full framerate or an FPS cap in the settings window. Set headset refresh in SteamVR or Virtual Desktop. Disable Virtual Desktop SSW or SteamVR motion smoothing for native-FPS testing."
try {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $pcRoot 'launch.ps1') -ForceMenu
} finally {
# The menu saves the selected settings before launching; archive those choices.
Copy-Item -LiteralPath (Join-Path $pcRoot 'settings.txt') -Destination $archive -Force
$logFolder = Join-Path $pcRoot 'user/log'
if (Test-Path -LiteralPath $logFolder) {
Get-ChildItem -LiteralPath $logFolder -File |
ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $archive }
}
$logPath = Join-Path $archive 'shad_log.txt'
if (Test-Path $logPath) {
Select-String -LiteralPath $logPath -Pattern 'Full-refresh recovery|Headset uses Vulkan queue|Headset queue|Report: Headset:|SetPace:|Change: The scene|first picture handed|title.s clock:|frame stats at|frame path:|frame shortcuts:' |
ForEach-Object { $_.Line } | Set-Content -LiteralPath (Join-Path $archive 'performance.txt') -Encoding UTF8
}
Write-Host "Archived this run in $archive"
}
+14 -62
View File
@@ -1,68 +1,20 @@
# Third-party notices
AstroQuest itself is licensed under GPL-2.0-or-later (see [LICENSE](LICENSE)). The release files
also contain, or are built with, the following parts made by others. Each keeps its own license;
the source of every part is listed with it.
AstroQuest-Frame and the shadPS4 emulator are licensed under GPL-2.0-or-later
(see [LICENSE](LICENSE)). The Steam Frame ARM64 release is built from this repository
and the dependencies listed below. Their license and notice files are included in the
release archive under `licenses/`.
## Quest app (`AstroQuest-<version>-Quest3.apk`)
| Part | License | Source |
| Component | License | Source |
| --- | --- | --- |
| The emulator (`shadps4-arm64-fex` in `assets/runtime.zip`): shadPS4 with the shadps4-arm64 changes and this project's | GPL-2.0-or-later | this repository, `shadps4-arm64-main/` (from [zenithblue-oss/shadps4-arm64](https://github.com/zenithblue-oss/shadps4-arm64), based on [shadPS4](https://github.com/shadps4-emu/shadPS4)) |
| The libraries built into the emulator (`shadps4-arm64-main/externals`: SDL3, fmt, Boost, glslang, sirit, Vulkan Memory Allocator, zlib-ng, xxHash, FFmpeg's core and others) | their own licenses (zlib, MIT, BSL-1.0, BSD, LGPL...), in each folder | the submodules listed in [.gitmodules](.gitmodules) |
| FEXCore, linked into the emulator | MIT | [FEX-Emu/FEX](https://github.com/FEX-Emu/FEX), fetched and patched by `shadps4-arm64-main/runtime/scripts/build-fexcore-smoke-aarch64.sh` |
| The app (`libastrovr.so`, `classes.dex`) and `libkgsl_compat.so` | GPL-2.0-or-later | this repository, `quest-host/` |
| LLVM libc++, linked into `libastrovr.so` | Apache-2.0 WITH LLVM-exception | [Android NDK](https://developer.android.com/ndk) / [LLVM](https://github.com/llvm/llvm-project) |
| Khronos OpenXR loader for Android 1.1.63 (`libopenxr_loader.so`) | Apache-2.0 | [KhronosGroup/OpenXR-SDK-Source](https://github.com/KhronosGroup/OpenXR-SDK-Source), release 1.1.63 |
| The GNU C Library 2.41 and its dynamic loader (`libc.so.6`, `libm`, `libdl`, `libpthread`, `librt`, `libresolv`, `libutil`, `ld-linux-aarch64.so.1`, the latter shipped as `libastro_ld.so`) | LGPL-2.1-or-later | Debian 13 `glibc` source package, with the Android compatibility patches in `shadps4-arm64-main/runtime/patches/glibc-android-sysvshm/` and `runtime/scripts/glibc-android-seccomp.mjs` |
| GCC runtime (`libstdc++.so.6`, `libgcc_s.so.1`) | GPL-3.0-or-later WITH GCC-exception-3.1 | Debian 13 `gcc-14` source package |
| X11 and XCB client libraries, libxkbcommon, libxshmfence, libdrm, libexpat, libffi, libwayland-client | MIT / X11 | Debian 13 source packages of the same names |
| libsystemd, libudev | LGPL-2.1-or-later | Debian 13 `systemd` source package |
| libdbus-1 | AFL-2.1 or GPL-2.0-or-later | Debian 13 `dbus` source package |
| libcap | BSD-3-Clause or GPL-2.0-only | Debian 13 `libcap2` source package |
| libuuid | BSD-3-Clause | Debian 13 `util-linux` source package |
| zlib | Zlib | Debian 13 `zlib` source package |
| Vulkan loader (`libvulkan.so.1`), SPIRV-Tools | Apache-2.0 | [KhronosGroup/Vulkan-Loader](https://github.com/KhronosGroup/Vulkan-Loader), [KhronosGroup/SPIRV-Tools](https://github.com/KhronosGroup/SPIRV-Tools), as packaged by Debian 13 |
| Mesa Turnip Vulkan driver for Adreno (`libvulkan_freedreno.so`) | MIT | [Vauzi-17/mesa-tu8](https://github.com/Vauzi-17/mesa-tu8), branch `gen8`, commit `9e646d59830fa220fff9a94ef2ed4a6ce418e254`, with Bachata S4's `kgsl-zero-timeout-poll` patch |
| shadPS4 and its bundled libraries | GPL-2.0-or-later; individual dependency licenses | [`shadps4-arm64-main/`](shadps4-arm64-main/), with dependency revisions in its submodules |
| FEXCore, used to run the game's x86-64 code on ARM64 | MIT | [FEX-Emu/FEX](https://github.com/FEX-Emu/FEX), fetched and patched by `shadps4-arm64-main/runtime/scripts/build-fexcore-smoke-aarch64.sh`; exact revision in `shadps4-arm64-main/runtime/locks/components.lock.json` |
| Khronos OpenXR loader | Apache-2.0 | [`shadps4-arm64-main/externals/openxr-sdk`](shadps4-arm64-main/externals/openxr-sdk) |
| Steam Linux Runtime 4 and SteamOS Vulkan driver | Their respective licenses | Installed by SteamOS; not distributed in the release archive |
The Linux runtime (everything from glibc to the Turnip driver above) is taken unchanged from
the [Bachata S4 v0.2.4](https://github.com/JICA98/Bachata-S4/releases/tag/v0.2.4) release,
whose notice (`shadps4-arm64-main/NOTICE.android-runtime.md`) and lock files
(`shadps4-arm64-main/runtime/locks/`) record the exact packages.
For the GPL and LGPL source corresponding to a release, use that release's source tag
and its recorded submodule and FEX revisions. A copy of the source is available on
request for three years from each release; open an issue in this repository.
## PC build (`AstroQuest-<version>-PC-VR-Windows.zip`)
| Part | License | Source |
| --- | --- | --- |
| The emulator (`shadps4.exe`) and the launcher (`launch.ps1`, `Play Astro Bot VR.bat`) | GPL-2.0-or-later | this repository |
| The libraries built into the emulator (`shadps4-arm64-main/externals`) | their own licenses, in each folder | the submodules listed in [.gitmodules](.gitmodules) |
| Khronos OpenXR loader 1.1.63, built into the emulator | Apache-2.0 | `shadps4-arm64-main/externals/openxr-sdk` ([KhronosGroup/OpenXR-SDK](https://github.com/KhronosGroup/OpenXR-SDK), release 1.1.63) |
| PkgTool and LibOrbisPkg 0.2.231 (`pc-vr/pkgtool`), unchanged: the launcher runs it to unpack a game package | LGPL-3.0 | [maxton/LibOrbisPkg](https://github.com/maxton/LibOrbisPkg), release [v0.2](https://github.com/maxton/LibOrbisPkg/releases/tag/v0.2) |
The emulator needs the Microsoft Visual C++ runtime, which is not included: it comes with the
[Visual C++ Redistributable](https://aka.ms/vs/17/release/vc_redist.x64.exe).
## Steam Frame build (`AstroQuest-SteamFrame-ARM64.tar.gz`)
The emulator, FEXCore and bundled dependencies use the same source and licenses
listed above. The Khronos OpenXR loader is built into the emulator. Steam Linux
Runtime 4 and the headset's Vulkan driver are provided by SteamOS and are not
distributed in this archive. Game and PS4 firmware files are not included.
Dependency license and notice files are included under `licenses/` in the archive.
The corresponding source is the release tag in
[mitch030504/AstroQuest](https://github.com/mitch030504/AstroQuest), with the exact
dependency revisions in `.gitmodules` and `runtime/locks/components.lock.json`
under `shadps4-arm64-main/`. Build using `tools/build-frame.sh`.
## Source of the GPL and LGPL parts
The source of AstroQuest itself is this repository, at the tag of each release. For the GPL
and LGPL parts made by others (the Linux runtime in the Quest app, the libraries built into
the emulators from `shadps4-arm64-main/externals`, and PkgTool), a copy of the exact source
used is available on request for three years from each release: open an issue on this
repository.
## Not included
No part of ASTRO BOT Rescue Mission, of the PlayStation 4 system software, or any key or other
copyrighted console file is part of this repository or of its releases.
The game and PlayStation firmware are not included. No part of ASTRO BOT Rescue
Mission, the PlayStation 4 system software, or any console key is included.
-41
View File
@@ -1,41 +0,0 @@
# Selected elliotttate performance port
Reviewed on 2026-10-07: [elliotttate/AstroQuest at b72db1b](https://github.com/elliotttate/AstroQuest/tree/b72db1b).
[Their measurements and limitations](https://github.com/elliotttate/AstroQuest/blob/b72db1b/docs/pc-performance.md).
Adapted pipeline-cache repairs and late-frame recovery from `6af3210`, preserving the original license headers. This candidate retains upstream 0.20, the soccer-enemy fix, Full framerate selector, expanded resolutions, separate headset queue and precise presentation timer.
## Included
Cached image/sampler lists now contain their storage, so a new process does not load pointers from the process that wrote the cache. Ordinary descriptor layouts avoid reading guest descriptors during preloading; dynamic-mip layouts requiring guest memory are deferred. Vertex-fetch data survives later shader stages, and shader permutations point at the persistent cached program's information.
Metadata and pipeline-key versions are incremented to reject the former layout. Upstream 0.20's shader-binary version 3 (geometry-input ABI fix) is preserved. Separate cold/warm processes test cache reuse before enabling it in the installed configuration.
When a recent stream's next frame is missing, the OpenXR thread waits briefly before repeating the last picture. The budget starts at xrWaitFrame wakeup, defaults to 3 ms, and is configurable with `SHADPS4_XR_LATE_WAIT_MS` (0 disables, maximum 6). Idle streams and pictures the runtime does not want are skipped. No Vulkan queue lock is held. The log counts recovered pictures and timeouts.
The initial condition-variable port's nominal 3-ms timeout took 15-16 ms on this PC. The Windows adaptation uses a producer event and a high-resolution timeout timer; measured timeout was about 3.5 ms. Arriving frames can wake it immediately. Unsupported precise timers disable waiting instead of using a coarse timeout. This is a scheduling budget, not a hard real-time guarantee.
## Kept separate
- Their native 90/120 engine mode is promising, but their 120 measurements use a simulator and game-1.00 offsets. The 90 mode is unmeasured and 1.04 offsets are unverified. It is not enabled here.
- Parallel texture copies, stream/shader memoization and lock-skipping are not imported wholesale. Their notes document unresolved memory corruption near the parallel-copy/shader-memo work; no causal fix is established.
- Their 32 presentation polls and high-resolution sleeps overlap with this fork. Their new delivery phase was measured at simulated 120 Hz; this fork retains its phase until a real-runtime comparison.
Startup checks do not establish improved headset gameplay. The existing regular release remains available for comparison.
## Precise guest timers and reduced desktop mirror updates (2026-10-07)
This candidate also adapts their precise guest HR-timer scheduling and mirror-rate limit. Windows HR events of 1.2 ms or longer use a dedicated high-resolution waitable timer thread, with a final 300 microseconds of deadline checking. Short HR timers and ordinary periodic timers retain their existing paths. Timer arm serials reject deleted/replaced events. Weak owner guards and a destruction lock prevent callbacks from accessing deleted event queues; the worker is joined at scheduler shutdown. Unsupported high-resolution timers use the existing asio implementation.
The local headset still receives every available frame. Its desktop spectator/stereo mirror defaults to 60 updates per second. A deadline accumulator preserves an average 60 updates at 72/90/120 Hz instead of rounding down to a divisor of the headset rate. Desktop-only and remote-export presentation remain unrestricted. The simple FPS counter counts game submissions rather than mirror updates.
For comparisons, add lines to `pc-vr/settings.txt` (restart the game):
- `env=SHADPS4_PERF_PRECISE_TIMERS=0`: original guest timer path.
- `env=SHADPS4_VR_WINDOW_FPS=0`: disable the local headset mirror.
- `env=SHADPS4_VR_WINDOW_FPS=1000`: effectively remove the mirror cap at normal headset rates.
- `env=SHADPS4_TIMER_SPIN_US=0`: precise timer sleeping without the final spin (default 300; maximum 5000).
Validation: 100 standalone 3-ms deadlines had 0.9 microseconds median lateness, 16.4 microseconds p95 and 68.5 microseconds maximum on this PC. Ordering, earlier-deadline wakeup, scheduling from callbacks and worker shutdown passed. Mirror tests produced 600 updates in ten seconds for 60/72/90/120/144 Hz sources, and checked disabled-mirror/desktop-only fallback. These are component tests, not headset gameplay measurements.
Three isolated SteamVR startup checks ran for 45 seconds each without a critical log error: precise timers with the 60-FPS mirror, precise timers with the mirror disabled, and original timers with the mirror cap effectively removed. The warm launch preloaded all 27 cached pipelines and compiled none. These checks used an idle headset and do not establish native gameplay FPS or judder improvements.
-1078
View File
File diff suppressed because it is too large. Load diff
-118
View File
@@ -1,118 +0,0 @@
# Settings for "Play Astro Bot VR.bat", one key=value a line. Lines starting with # are ignored;
# what is commented out below shows the value that applies when the line is left out.
# The window at the start (menu=0 to go without it) sets resolution, framerate and fov below.
# The width of each eye's picture: 1440 is the console's largest (what a PlayStation 4 Pro
# draws, 1440x1536); 1800, 2160, 2520, 2880, 3240, 3600, 3960 or 4320 have the game draw that much larger
# (2880 = 2880x3072, four times the pixels), with the memory that takes. The game draws a step
# smaller by itself while the graphics card cannot keep up; dynamic=0 holds it to the size.
# game: the console's sizes, chosen by the game itself (816 to 1440).
#resolution=2880
#dynamic=1
# Full-refresh pacing is the default. Set headset refresh in SteamVR or Virtual Desktop.
# fps=full targets headset refresh. Numeric FPS choices set a maximum using whole refresh intervals.
#fps=full
# How much of the headset's field of view the game draws, in percent, 70 to 100: at 100 the
# picture fills all that the headset shows; less puts the same pixels over fewer degrees, a
# sharper picture with a dark border where the rest was.
#fov=100
# What fov is a percent of: headset (the headset being worn, whatever it is: the emulator asks
# it as the game starts) or psvr (a PlayStation VR's 100 by 103 degrees an eye, the view the
# game was made for, which leaves a narrow dark margin far out to the sides on a Quest 3).
#fov_of=headset
# 1: the window with the main settings comes up at every start.
#menu=1
# Sharpening of the picture on its way to the headset, 0 (none) to 1. Virtual Desktop has a
# sharpening of its own (Streaming tab): what is here comes on top of it.
#sharpen=0.3
# The most samples a pixel gets (1, 2, 4, 8). Left out: as the console draws it (4). With 1 the
# emulator smooths edges itself instead, unless antialias=0.
#msaa=4
#antialias=1
# 0: your hands are not used to place the controller in the game; it then stays at a fixed
# spot in front of you, turned by its own motion sensors. (The hands are what the headset
# tracks: hand tracking has to be on in the headset, and Virtual Desktop has to pass it on to
# the PC - its setting for forwarding tracking data. Where it does not, the places it gives
# the headset's controllers stand in, which it makes up from the hands while they lie unused.)
# The DualSense must be connected to this PC itself (USB cable or Bluetooth): paired with the
# headset, it reaches the PC through Virtual Desktop without motion sensors and touchpad.
#hands=1
# How far beyond the next picture the head position given to the game is predicted, in
# milliseconds (0 to 80). More if black edges show when you turn your head, less if the world
# wobbles.
#predict_ms=20
# 0: the right stick no longer doubles as a finger on the touchpad.
#stick_touchpad=1
# Makes what the microphone hears that many times louder for the game (0.1 to 30), for when
# blowing does too little. The launcher's window shows how loud the game hears things, and
# what it takes for blowing. (Holding the PS button and square blows without a microphone.)
#mic_gain=1
# 0: the headset's own controllers never stand in for a gamepad. (Otherwise they play while no
# gamepad is connected to the PC, and with one whenever they were used after it.)
#controllers=1
# Which of the headset's controllers is the controller in the game while they stand in for a
# gamepad: right or left.
#controller_hand=right
# The language the game is played in, as Windows names languages (fr-FR, de-DE, pt-BR, ja-JP
# ...). windows: the one Windows is shown in. The game has 28 languages and is in English
# for any other. The launcher's window has the list.
#language=windows
# How many degrees the view turns when L1 (the headset's controllers: the left grip) is held
# and the right stick flicked to a side, for those who cannot turn round where they sit.
# 0: never.
#turn=30
# 0: the game's surround sound is folded down to left and right instead of being rendered for
# the headset's speakers.
#surround=1
# 0: the game is never made to wait. (It waits, black, while the headset is off the head or
# shows something else, Virtual Desktop's view of the desktop for one, and goes on after.) For
# when it waits although the headset is on and shows the game.
#pause=1
# 0: the game counts time in frames, as on the console (slow motion whenever a frame takes
# longer than a sixtieth of a second).
#real_time=1
# The older way to say fps: how many of the headset's pictures every frame is given (1, 2, 3...).
# Takes the place of fps when set.
#pace=2
# Seconds to wait for a headset before the game starts on the monitor without one. A headset
# connected later is picked up anyway, wherever the game is by then. 0: do not wait.
#wait=60
# 0: do not look for a headset at all, play on the monitor.
#headset=1
# Where the game is, if it is not in the games folder next to this one: its eboot.bin, its
# folder, or its .pkg package (which is then unpacked into the games folder). The launcher
# writes this line itself when it has to ask where the game is.
#game=C:\path\to\CUSA12392\eboot.bin
# Extra environment variables for the emulator, as many lines as needed.
#env=NAME=value
# Precise guest timers default on; set 0 to compare the original timer path.
#env=SHADPS4_PERF_PRECISE_TIMERS=0
# Local headset desktop mirror defaults to 60 FPS; 0 disables, 1000 removes its cap.
#env=SHADPS4_VR_WINDOW_FPS=60
resolution=3600
fps=72
fov=100
menu=1
-97
View File
@@ -1,97 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Immersive OpenXR host for the shadPS4 ARM64 core on Meta Quest. The manifest follows Meta's
"Android Manifest Settings" page for native apps (activity attributes, VR intent category,
head-tracking feature, supported devices) plus what the Khronos OpenXR loader needs to find
the runtime (permissions and queries, copied from openxr_loader_for_android's own manifest).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.astrobotquest.vrhost"
android:versionCode="20"
android:versionName="0.20"
android:installLocation="auto">
<uses-sdk
android:minSdkVersion="32"
android:targetSdkVersion="32" />
<uses-feature
android:name="android.hardware.vr.headtracking"
android:required="true"
android:version="1" />
<uses-feature
android:glEsVersion="0x00030001"
android:required="true" />
<!--
The game is played with a gamepad, held in hands the headset tracks, or with the
headset's own controllers. Declaring hand tracking tells Horizon OS that those
controllers are not needed; without it the launch is blocked by a "controllers required"
dialog whenever they are asleep.
-->
<uses-permission android:name="com.oculus.permission.HAND_TRACKING" />
<uses-feature
android:name="oculus.software.handtracking"
android:required="false" />
<uses-permission android:name="org.khronos.openxr.permission.OPENXR" />
<uses-permission android:name="org.khronos.openxr.permission.OPENXR_SYSTEM" />
<!-- Lets the app see a paired DualSense's rumble motors. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!--
The game listens to the headset's microphone (blowing at things in its world), as it did
to PlayStation VR's. Asked for when the app starts; without it the game hears silence.
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Development aid, see SandboxShell.java. -->
<instrumentation
android:name="com.astrobotquest.vrhost.SandboxShell"
android:targetPackage="com.astrobotquest.vrhost" />
<queries>
<provider android:authorities="org.khronos.openxr.runtime_broker;org.khronos.openxr.system_runtime_broker" />
<intent>
<action android:name="org.khronos.openxr.OpenXRRuntimeService" />
</intent>
<intent>
<action android:name="org.khronos.openxr.OpenXRApiLayerService" />
</intent>
</queries>
<!--
extractNativeLibs: the glibc dynamic loader ships as lib/arm64-v8a/libastro_ld.so and has to
exist as a real file the app may execute.
-->
<application
android:label="@string/app_name"
android:allowBackup="false"
android:extractNativeLibs="true"
android:hasCode="true"
android:appCategory="game">
<meta-data
android:name="com.oculus.supportedDevices"
android:value="quest3|quest3s" />
<!-- The hands are followed to place the controller they hold: as often as possible. -->
<meta-data
android:name="com.oculus.handtracking.frequency"
android:value="HIGH" />
<activity
android:name="com.astrobotquest.vrhost.MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
android:configChanges="density|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|uiMode"
android:launchMode="singleTask"
android:resizeableActivity="false">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
</activity>
</application>
</manifest>
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env bash
# Builds the Quest host APK without Gradle or Android Studio projects:
# NDK clang -> libastrovr.so, javac + d8 -> classes.dex, aapt2 -> APK, zipalign + apksigner.
#
# The Linux runtime the emulator core runs in (glibc, its loader, the Turnip Vulkan driver) is
# taken from the user's copy of the Bachata S4 APK at build time, and the core itself from
# build/arm64 (tools/build-arm64.sh).
#
# quest-host/build.sh [--driver <zip inside the APK's assets/drivers>] [--trade-cpu-for-gpu]
# [--test-package]
#
# --trade-cpu-for-gpu builds the app that gives up one processor clock level (1.92 -> 1.65 GHz
# on a Quest 3) for one more GPU clock level (up to 640 MHz instead of 599). The system only
# takes that choice from the manifest, so it is a build of its own.
#
# --test-package builds the same app under another name (com.astrobotquest.vrhost.test,
# "Astro VR Host (test)", build/quest/astro-vr-host-test.apk, debug key): it installs next to
# the app proper and touches neither it nor its saves, for trying a build on a headset whose
# owner plays with the other one (the tools take ASTRO_PACKAGE for which of the two they run).
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
host="$root/quest-host"
out="$root/build/quest"
NDK=${ANDROID_NDK:-"/c/Program Files/Unity/Hub/Editor/6000.3.2f1/Editor/Data/PlaybackEngines/AndroidPlayer/NDK"}
SDK=${ANDROID_SDK:-"$LOCALAPPDATA/Android/Sdk"}
BUILD_TOOLS="$SDK/build-tools/36.1.0"
PLATFORM_JAR="$SDK/platforms/android-36/android.jar"
JDK=${JAVA_HOME:-"/c/Program Files/Android/Android Studio/jbr"}
# Python: $PYTHON, or the one named in tools/python.local (a full path), or the one on PATH.
PYTHON=${PYTHON:-$(cat "$root/tools/python.local" 2>/dev/null || command -v python3 || command -v python)}
BACHATA_APK="$root/bachatas4-0.2.4-release.apk"
CORE="$root/build/arm64/shadps4"
OPENXR="$root/tools/openxr"
driver="turnip-vauzi-7xx-EMULATOR.zip"
# Source paths in the binaries are counted from this folder, not from the machine's root.
prefix_map="-ffile-prefix-map=$(cygpath -m "$root")/="
trade=""
test_package=""
while [[ $# -gt 0 ]]; do
case "$1" in
--driver) driver=$2; shift 2 ;;
--trade-cpu-for-gpu) trade=1; shift ;;
--test-package) test_package=com.astrobotquest.vrhost.test; shift ;;
*) echo "unknown option $1" >&2; exit 64 ;;
esac
done
export JAVA_HOME="$JDK"
export PATH="$JDK/bin:$PATH"
for needed in "$NDK/toolchains/llvm/prebuilt/windows-x86_64/bin/clang++.exe" "$BUILD_TOOLS/aapt2.exe" \
"$PLATFORM_JAR" "$JDK/bin/javac.exe" "$PYTHON" "$BACHATA_APK" "$CORE" \
"$OPENXR/jni/arm64-v8a/libopenxr_loader.so" \
"$root/tools/llvm/bin/aarch64-linux-gnu-clang.exe"; do
[[ -e "$needed" ]] || { echo "missing: $needed" >&2; exit 1; }
done
rm -rf "$out/apk" "$out/classes" "$out/dex" "$out/runtime" "$out/bachata"
mkdir -p "$out/apk/lib/arm64-v8a" "$out/apk/assets" "$out/classes" "$out/dex" "$out/runtime" "$out/bachata"
echo "== runtime"
# The glibc world: libraries and the loader (which Android only lets us execute from the
# app's native library folder, hence its lib*.so name).
unzip -q -o "$BACHATA_APK" "assets/runtime/runtime.zip" "assets/drivers/$driver" \
"lib/arm64-v8a/libbachata_host_loader.so" -d "$out/bachata"
unzip -q -o "$out/bachata/assets/runtime/runtime.zip" "host/*" -d "$out/runtime"
rm -rf "$out/runtime/host/shadps4-arm64-fex" "$out/runtime/host/fexcore-guest-harness" \
"$out/runtime/host/fexcore-smoke" "$out/runtime/host/lib" "$out/runtime/host/vulkan"
cp "$out/bachata/lib/arm64-v8a/libbachata_host_loader.so" "$out/apk/lib/arm64-v8a/libastro_ld.so"
mkdir -p "$out/runtime/drivers/turnip"
unzip -q -o "$out/bachata/assets/drivers/$driver" -d "$out/runtime/drivers/turnip"
[[ -f "$out/runtime/drivers/turnip/freedreno_icd.aarch64.json" ]] || {
echo "$driver is not a glibc Turnip package (no ICD manifest)" >&2; exit 1; }
"$root/tools/llvm/bin/llvm-strip.exe" -o "$out/runtime/host/shadps4-arm64-fex" "$CORE"
# Translates the GPU memory calls Horizon OS denies to apps into the ones it allows. A glibc
# library like the rest of the runtime, so it is built with the same cross compiler as the core.
"$root/tools/llvm/bin/aarch64-linux-gnu-clang.exe" -O2 -fPIC -shared "$prefix_map" \
"$(cygpath -m "$host/runtime/kgsl_compat.c")" -lpthread \
-o "$(cygpath -m "$out/runtime/host/libkgsl_compat.so")"
"$PYTHON" - "$out/runtime" "$out/apk/assets/runtime.zip" "$out/apk/assets/runtime.stamp" <<'PY'
import hashlib, os, sys, zipfile
source, target, stamp = sys.argv[1:4]
digest = hashlib.sha256()
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for folder, _, files in sorted(os.walk(source)):
for name in sorted(files):
path = os.path.join(folder, name)
relative = os.path.relpath(path, source).replace(os.sep, "/")
with open(path, "rb") as handle:
data = handle.read()
digest.update(relative.encode() + b"\0" + data)
# A fixed timestamp keeps the archive, and so the stamp, reproducible.
info = zipfile.ZipInfo(relative, (2026, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o755 << 16
archive.writestr(info, data)
with open(stamp, "w") as handle:
handle.write(digest.hexdigest() + "\n")
print("runtime.zip", os.path.getsize(target) // (1 << 20), "MB")
PY
echo "== native"
"$NDK/toolchains/llvm/prebuilt/windows-x86_64/bin/clang++.exe" --target=aarch64-linux-android32 \
-std=c++20 -O2 -fPIC -fvisibility=hidden "$prefix_map" -Wall -Wextra -Wno-unused-parameter \
-Wno-missing-field-initializers \
-I "$(cygpath -m "$OPENXR/prefab/modules/headers/include")" \
-I "$(cygpath -m "$root/shadps4-arm64-main/src/core/vr")" \
-I "$(cygpath -m "$root/shadps4-arm64-main/src/input")" \
"$(cygpath -m "$host/cpp/main.cpp")" "$(cygpath -m "$host/cpp/core_process.cpp")" \
"$(cygpath -m "$host/cpp/xr_host.cpp")" "$(cygpath -m "$host/cpp/gl_frames.cpp")" \
"$(cygpath -m "$host/cpp/self_test.cpp")" "$(cygpath -m "$host/cpp/log.cpp")" \
"$(cygpath -m "$host/cpp/xr_probe.cpp")" \
-shared -static-libstdc++ -Wl,--no-undefined -Wl,-soname,libastrovr.so \
-L "$(cygpath -m "$OPENXR/jni/arm64-v8a")" -lopenxr_loader \
-lEGL -lGLESv3 -landroid -lnativewindow -laaudio -llog -lz \
-o "$(cygpath -m "$out/apk/lib/arm64-v8a/libastrovr.so")"
cp "$OPENXR/jni/arm64-v8a/libopenxr_loader.so" "$out/apk/lib/arm64-v8a/"
echo "== java"
javac -Xlint:-options -source 11 -target 11 -classpath "$(cygpath -m "$PLATFORM_JAR")" \
-d "$(cygpath -m "$out/classes")" \
"$(cygpath -m "$host/java/com/astrobotquest/vrhost/MainActivity.java")" \
"$(cygpath -m "$host/java/com/astrobotquest/vrhost/SandboxShell.java")" \
"$(cygpath -m "$host/java/com/astrobotquest/vrhost/RuntimeInstaller.java")"
# d8 is a batch file around a jar; call the jar so paths with spaces survive.
java -cp "$(cygpath -m "$BUILD_TOOLS/lib/d8.jar")" com.android.tools.r8.D8 \
--lib "$(cygpath -m "$PLATFORM_JAR")" --min-api 32 --output "$(cygpath -m "$out/dex")" \
$(find "$out/classes" -name "*.class" -exec cygpath -m {} \;)
cp "$out/dex/classes.dex" "$out/apk/classes.dex"
echo "== package"
manifest="$host/AndroidManifest.xml"
if [[ -n "$trade" ]]; then
# Per Meta's "Trading CPU and GPU levels": 1 = one level more for the GPU, one less for the
# processor.
manifest="$out/AndroidManifest.xml"
sed 's|^\( *\)<activity$|\1<meta-data\n\1 android:name="com.oculus.trade_cpu_for_gpu_amount"\n\1 android:value="1" />\n\n\1<activity|' \
"$host/AndroidManifest.xml" > "$manifest"
grep -q trade_cpu_for_gpu_amount "$manifest" || { echo "could not add the trade to the manifest" >&2; exit 1; }
echo " with one processor level traded for one GPU level"
fi
res="$host/res"
rename=()
apk="$out/astro-vr-host.apk"
if [[ -n "$test_package" ]]; then
# Another name for the system and for the eye; the classes keep theirs.
res="$out/res-test"
rm -rf "$res"
cp -r "$host/res" "$res"
sed -i 's|>Astro VR Host<|>Astro VR Host (test)<|' "$res/values/strings.xml"
grep -q "(test)" "$res/values/strings.xml" || { echo "could not rename the test app" >&2; exit 1; }
rename=(--rename-manifest-package "$test_package"
--rename-instrumentation-target-package "$test_package")
apk="$out/astro-vr-host-test.apk"
echo " as $test_package"
fi
"$BUILD_TOOLS/aapt2.exe" compile --dir "$(cygpath -m "$res")" -o "$(cygpath -m "$out/res.zip")"
"$BUILD_TOOLS/aapt2.exe" link -o "$(cygpath -m "$out/base.apk")" \
-I "$(cygpath -m "$PLATFORM_JAR")" --manifest "$(cygpath -m "$manifest")" \
-A "$(cygpath -m "$out/apk/assets")" --debug-mode "${rename[@]}" \
--min-sdk-version 32 --target-sdk-version 32 "$(cygpath -m "$out/res.zip")"
"$PYTHON" - "$out/base.apk" "$out/apk" <<'PY'
import os, sys, zipfile
apk, staging = sys.argv[1:3]
with zipfile.ZipFile(apk, "a", zipfile.ZIP_DEFLATED) as archive:
archive.write(os.path.join(staging, "classes.dex"), "classes.dex")
libs = os.path.join(staging, "lib", "arm64-v8a")
for name in sorted(os.listdir(libs)):
archive.write(os.path.join(libs, name), "lib/arm64-v8a/" + name)
PY
"$BUILD_TOOLS/zipalign.exe" -p -f 4 "$(cygpath -m "$out/base.apk")" "$(cygpath -m "$out/aligned.apk")"
# An update only installs over an app signed with the same key. Releases are signed with the
# key named in tools/signing.local (two lines: the keystore, then a file holding its password;
# neither belongs in the repository), other builds with the Android SDK's debug key.
if [[ -f "$root/tools/signing.local" && -z "$test_package" ]]; then
{ read -r keystore; read -r password_file; } < <(tr -d '\r' < "$root/tools/signing.local")
# (Its key has the keystore's password: apksigner then needs no --key-pass.)
signing=(--ks "$keystore" --ks-pass "file:$password_file")
echo " signed with $(basename "$keystore")"
else
signing=(--ks "$(cygpath -m "$HOME/.android/debug.keystore")" --ks-pass pass:android
--key-pass pass:android)
echo " signed with the debug key"
fi
java -jar "$(cygpath -m "$BUILD_TOOLS/lib/apksigner.jar")" sign "${signing[@]}" \
--out "$(cygpath -m "$apk")" "$(cygpath -m "$out/aligned.apk")"
ls -la "$apk"
File diff suppressed because it is too large. Load diff
-144
View File
@@ -1,144 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <array>
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#include <sys/types.h>
#include "pad_state.h"
#include "vr_protocol.h"
/// Whether the game may listen to the headset's microphone, and how much louder than it is
/// what it hears should be made.
struct MicrophoneSettings {
bool enabled{true};
float gain{1.0f};
/// For tests nobody is there to blow for: instead of the microphone, a second of noise
/// every five, as loud as the game takes blowing hard to be.
bool test_signal{};
/// Set while the player blows with buttons instead of with their breath: the game then
/// hears that same noise, whatever the microphone has to say (or is allowed to).
std::shared_ptr<const std::atomic<bool>> blowing;
};
/// Everything needed to start the emulator core.
struct CoreLaunch {
std::string loader; ///< glibc's dynamic loader, inside the app's native library folder
std::string runtime_root; ///< extracted runtime: host/ (libraries, core), drivers/
std::string storage_root; ///< writable folder the core keeps its data in
std::string game; ///< path of the game's eboot.bin
std::string log_file;
std::vector<std::string> extra_args;
std::vector<std::pair<std::string, std::string>> extra_env;
};
/// Runs the emulator core as a child process and serves the sockets it talks to: the control
/// line protocol (state out, controller in), audio, and the VR frame/pose channel.
class CoreProcess {
public:
enum class State { Idle, Starting, Running, Stopped, Failed };
CoreProcess() = default;
~CoreProcess();
bool Start(const CoreLaunch& launch);
void Stop();
State GetState() const {
return state.load();
}
/// Human-readable reason once the state is Stopped or Failed.
std::string GetMessage();
/// Frames the core reported as presented so far.
uint64_t GetPresentedFrames() const {
return presented_frames.load();
}
void SetPad(const PadState& pad);
/// Freezes the emulator while nobody is looking (headset taken off), or lets it go on.
void SetPaused(bool paused);
/// Offers the frame buffers to the core. `fds` are dma-bufs, they stay owned by the caller.
void SetBuffers(const std::vector<int>& fds, uint32_t width, uint32_t height, uint32_t stride);
void SendPose(const Core::Vr::Protocol::Pose& pose);
void SendPadPose(const Core::Vr::Protocol::PadPose& pose);
void SendOptics(float ipd);
/// The display refreshes: the emulated headset keeps step with it (`rate` in Hz).
void SendRefresh(float rate);
/// Returns the newest frame the core finished since the last call, if any.
std::optional<Core::Vr::Protocol::Frame> TakeFrame();
/// What the game last asked of the controller, packed: small motor, large motor, red,
/// green, blue, one byte each from the lowest up.
uint64_t GetPadFeedback() const {
return pad_feedback.load();
}
/// What the emulator process has used: processor time since it started, the processors it
/// may run on (as the system lists them, "0-5") and its memory.
struct Usage {
double cpu_seconds{};
std::string cpus;
int memory_mb{};
};
Usage GetUsage() const;
/// Applies to microphone ports the game opens from now on.
void SetMicrophone(bool enabled, float gain, bool test_signal = false);
/// The player blows with buttons, or has stopped: what the game hears follows at once.
void SetBlowing(bool blowing_) {
blowing->store(blowing_, std::memory_order_relaxed);
}
/// Thread id of the core's thread whose name starts with `prefix`, or 0.
pid_t FindThread(const std::string& prefix) const;
/// All threads of the emulator whose names begin with `prefix`.
std::vector<pid_t> FindThreads(const std::string& prefix) const;
private:
void ControlLoop();
void AudioLoop();
void VrLoop();
void WaitLoop();
void SendBuffersLocked();
void Fail(const std::string& message);
std::atomic<State> state{State::Idle};
std::atomic<bool> quit{};
std::atomic<bool> paused{};
std::atomic<uint64_t> presented_frames{};
std::atomic<uint64_t> pad_feedback{};
std::atomic<pid_t> pid{-1};
int control_listen{-1};
int audio_listen{-1};
int vr_listen{-1};
std::atomic<int> control_fd{-1};
std::atomic<int> vr_fd{-1};
std::thread control_thread;
std::thread audio_thread;
std::thread vr_thread;
std::thread wait_thread;
MicrophoneSettings microphone;
/// Shared with the threads that serve the game's microphone ports, which may outlast this.
std::shared_ptr<std::atomic<bool>> blowing{std::make_shared<std::atomic<bool>>(false)};
std::mutex mutex;
std::string message;
PadState pad;
std::optional<Core::Vr::Protocol::Frame> frame;
std::vector<int> buffer_fds;
Core::Vr::Protocol::Buffers buffers{};
bool buffers_sent{};
};
-282
View File
@@ -1,282 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "gl_frames.h"
#include <cstring>
#include <android/hardware_buffer.h>
#include <dlfcn.h>
#include "core_process.h"
#include "log.h"
namespace {
/// How gralloc describes a buffer; the first file descriptor is the dma-buf with the pixels.
struct NativeHandle {
int version;
int num_fds;
int num_ints;
int data[1];
};
GLuint CompileShader(GLenum type, const char* source) {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint ok = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
if (ok != GL_TRUE) {
char log[512];
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
LOGE("shader compilation failed: %s", log);
}
return shader;
}
const char* const BlitVertexShader = R"(#version 300 es
out vec2 uv;
void main() {
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
// The frame's first row is its top row, GL's first row is the bottom one.
uv = vec2(corner.x, 1.0 - corner.y);
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* const BlitFragmentShader = R"(#version 300 es
precision highp float;
uniform sampler2D frame;
uniform int swap_red_blue;
uniform int decode_srgb;
uniform int reduced_fov;
uniform vec4 fov_tan;
in vec2 uv;
out vec4 color;
void main() {
vec3 c = texture(frame, uv).rgb;
if (swap_red_blue != 0) {
c = c.bgr;
}
if (reduced_fov != 0) {
// Each eye's aperture is centred on straight ahead, not on the texture midpoint.
// With asymmetric headset optics those midpoints point in opposite directions.
vec2 eye_uv = vec2(fract(uv.x * 2.0), uv.y);
vec2 axis = vec2((uv.x < 0.5 ? fov_tan.x : fov_tan.y) /
(fov_tan.x + fov_tan.y),
fov_tan.z / (fov_tan.z + fov_tan.w));
vec2 radius = mix(axis, vec2(1.0) - axis, step(axis, eye_uv));
// The pillowed rectangle reaches each frustum edge, with its curved corners around
// the optical axis. Only visibility changes; the scene and lens warp are untouched.
vec2 q = abs((eye_uv - axis) / radius);
vec2 q2 = q * q;
float edge = dot(q2, q2);
float feather = max(0.10, 1.5 * fwidth(edge));
// Finish the fade just inside the image, so filtering never leaks a colored seam
// from the other eye or leaves a hard line at the projection's rectangular bounds.
c *= 1.0 - smoothstep(0.98 - feather, 0.98, edge);
}
// The emulator delivers display-ready values. An sRGB target encodes whatever is written to
// it, so those have to be made linear first.
if (decode_srgb != 0) {
c = mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
}
color = vec4(c, 1.0);
}
)";
} // namespace
bool GlContext::Create() {
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) {
LOGE("eglInitialize failed");
return false;
}
const EGLint config_attributes[] = {
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
EGL_DEPTH_SIZE, 0, EGL_NONE,
};
EGLint num_configs = 0;
if (!eglChooseConfig(display, config_attributes, &config, 1, &num_configs) ||
num_configs < 1) {
LOGE("eglChooseConfig failed");
return false;
}
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
// Nothing is drawn to this surface, the context just needs one to be current.
const EGLint surface_attributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
surface = eglCreatePbufferSurface(display, config, surface_attributes);
if (context == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE ||
!eglMakeCurrent(display, surface, surface, context)) {
LOGE("unable to create the GL context: 0x%x", eglGetError());
return false;
}
return true;
}
void GlContext::Destroy() {
if (display == EGL_NO_DISPLAY) {
return;
}
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (surface != EGL_NO_SURFACE) {
eglDestroySurface(display, surface);
}
if (context != EGL_NO_CONTEXT) {
eglDestroyContext(display, context);
}
eglTerminate(display);
display = EGL_NO_DISPLAY;
surface = EGL_NO_SURFACE;
context = EGL_NO_CONTEXT;
}
bool FrameBuffers::Create(EGLDisplay display, uint32_t width, uint32_t height,
CoreProcess& core) {
using GetNativeHandle = const NativeHandle* (*)(const AHardwareBuffer*);
const auto get_native_handle = reinterpret_cast<GetNativeHandle>(
dlsym(RTLD_DEFAULT, "AHardwareBuffer_getNativeHandle"));
if (get_native_handle == nullptr) {
LOGE("AHardwareBuffer_getNativeHandle is not available");
return false;
}
AHardwareBuffer_Desc description{};
description.width = width;
description.height = height;
description.layers = 1;
description.format = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM;
// The CPU bits keep the buffer in a plain row-by-row layout, which is what the emulator's
// GPU driver can attach to and what its fallback path writes.
description.usage =
AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER |
AHARDWAREBUFFER_USAGE_CPU_READ_RARELY | AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY;
std::vector<int> fds;
uint32_t stride = 0;
for (uint32_t i = 0; i < Count; ++i) {
AHardwareBuffer* buffer = nullptr;
if (AHardwareBuffer_allocate(&description, &buffer) != 0) {
LOGE("unable to allocate frame buffer %u", i);
return false;
}
buffers.push_back(buffer);
AHardwareBuffer_Desc actual{};
AHardwareBuffer_describe(buffer, &actual);
stride = actual.stride;
const NativeHandle* handle = get_native_handle(buffer);
if (handle == nullptr || handle->num_fds < 1) {
LOGE("frame buffer %u has no file descriptor", i);
return false;
}
fds.push_back(handle->data[0]);
const EGLClientBuffer client_buffer = eglGetNativeClientBufferANDROID(buffer);
const EGLint image_attributes[] = {EGL_IMAGE_PRESERVED_KHR, EGL_TRUE, EGL_NONE};
const EGLImageKHR image = eglCreateImageKHR(display, EGL_NO_CONTEXT,
EGL_NATIVE_BUFFER_ANDROID, client_buffer,
image_attributes);
if (image == EGL_NO_IMAGE_KHR) {
LOGE("unable to wrap frame buffer %u in an EGL image: 0x%x", i, eglGetError());
return false;
}
images.push_back(image);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, image);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
textures.push_back(texture);
}
LOGI("frame buffers: %u x %ux%u, stride %u", Count, width, height, stride);
core.SetBuffers(fds, width, height, stride);
return true;
}
void FrameBuffers::Destroy(EGLDisplay display) {
for (const GLuint texture : textures) {
glDeleteTextures(1, &texture);
}
for (const EGLImageKHR image : images) {
eglDestroyImageKHR(display, image);
}
for (AHardwareBuffer* buffer : buffers) {
AHardwareBuffer_release(buffer);
}
textures.clear();
images.clear();
buffers.clear();
}
bool FrameBlitter::Create() {
const GLuint vertex = CompileShader(GL_VERTEX_SHADER, BlitVertexShader);
const GLuint fragment = CompileShader(GL_FRAGMENT_SHADER, BlitFragmentShader);
program = glCreateProgram();
glAttachShader(program, vertex);
glAttachShader(program, fragment);
glLinkProgram(program);
glDeleteShader(vertex);
glDeleteShader(fragment);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE) {
LOGE("unable to link the blit program");
return false;
}
uniform_swap = glGetUniformLocation(program, "swap_red_blue");
uniform_decode = glGetUniformLocation(program, "decode_srgb");
uniform_mask = glGetUniformLocation(program, "reduced_fov");
uniform_fov = glGetUniformLocation(program, "fov_tan");
glGenVertexArrays(1, &vertex_array);
const char* extensions = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
write_control =
extensions != nullptr && std::strstr(extensions, "GL_EXT_sRGB_write_control") != nullptr;
return true;
}
void FrameBlitter::Destroy() {
if (program != 0) {
glDeleteProgram(program);
program = 0;
}
if (vertex_array != 0) {
glDeleteVertexArrays(1, &vertex_array);
vertex_array = 0;
}
}
void FrameBlitter::Draw(GLuint texture, uint32_t width, uint32_t height, bool swap_red_blue,
bool srgb_target, bool reduced_fov, const float* fov) const {
glViewport(0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height));
glDisable(GL_BLEND);
glDisable(GL_DEPTH_TEST);
glDisable(GL_SCISSOR_TEST);
const bool unencoded = srgb_target && write_control;
if (unencoded) {
glDisable(GL_FRAMEBUFFER_SRGB_EXT);
}
glUseProgram(program);
glUniform1i(uniform_swap, swap_red_blue ? 1 : 0);
glUniform1i(uniform_decode, srgb_target && !write_control ? 1 : 0);
glUniform1i(uniform_mask, reduced_fov ? 1 : 0);
glUniform4f(uniform_fov, fov != nullptr ? fov[0] : 1.0f,
fov != nullptr ? fov[1] : 1.0f, fov != nullptr ? fov[2] : 1.0f,
fov != nullptr ? fov[3] : 1.0f);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(vertex_array);
glDrawArrays(GL_TRIANGLES, 0, 3);
if (unencoded) {
// As it is by default: everything else drawn to such a target counts on it.
glEnable(GL_FRAMEBUFFER_SRGB_EXT);
}
}
-86
View File
@@ -1,86 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
// The GL side of showing the emulator's frames, shared by the OpenXR host and by the self test
// that exercises the same path without a headset session.
#include <cstdint>
#include <vector>
// Extension entry points (EGL images over hardware buffers) are only declared on request.
#define EGL_EGLEXT_PROTOTYPES
#define GL_GLEXT_PROTOTYPES
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES3/gl3.h>
// The extension header relies on the types the one above defines.
#include <GLES2/gl2ext.h>
struct AHardwareBuffer;
class CoreProcess;
/// An OpenGL ES 3 context that draws to nothing: everything is rendered to textures.
struct GlContext {
EGLDisplay display{EGL_NO_DISPLAY};
EGLConfig config{};
EGLContext context{EGL_NO_CONTEXT};
EGLSurface surface{EGL_NO_SURFACE};
bool Create();
void Destroy();
};
/// The images the emulator renders into: hardware buffers handed to it as dma-bufs, each of them
/// also a GL texture here, so that a frame gets from one process to the other without a copy.
class FrameBuffers {
public:
static constexpr uint32_t Count = 3;
/// Allocates the buffers and offers them to the core. Needs a current GL context.
bool Create(EGLDisplay display, uint32_t width, uint32_t height, CoreProcess& core);
void Destroy(EGLDisplay display);
bool IsValid(uint32_t index) const {
return index < textures.size();
}
GLuint Texture(uint32_t index) const {
return textures[index];
}
private:
std::vector<AHardwareBuffer*> buffers;
std::vector<EGLImageKHR> images;
std::vector<GLuint> textures;
};
/// Copies a frame to the framebuffer that is bound, upside up and in the right color encoding.
class FrameBlitter {
public:
bool Create();
void Destroy();
/// `srgb_target` is for sRGB targets: they encode whatever is written to them, while the
/// emulator delivers values that are display-ready already. Where the GPU can be told to
/// leave that encoding out, the frame is written as it is; elsewhere it is decoded first,
/// which costs a power function per pixel.
/// `reduced_fov` rounds each eye's boundary into a feathered squircle. The game already
/// rendered the narrower projection; masking its corners preserves its pixel density.
/// `fov` is that frame's out/in/up/down tangents: the aperture follows each eye's optical
/// axis, which need not be at the centre of an asymmetric projection's texture.
void Draw(GLuint texture, uint32_t width, uint32_t height, bool swap_red_blue,
bool srgb_target, bool reduced_fov = false, const float* fov = nullptr) const;
/// Whether frames go into sRGB targets unchanged (see Draw).
bool WritesUnencoded() const {
return write_control;
}
private:
GLuint program{};
GLuint vertex_array{};
GLint uniform_swap{-1};
GLint uniform_decode{-1};
GLint uniform_mask{-1};
GLint uniform_fov{-1};
bool write_control{};
};
-167
View File
@@ -1,167 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <cmath>
#include <optional>
/// Where a gamepad is and how it is held, told by the two hands around it. A gamepad cannot
/// be tracked, the hands that hold it can: the palms a controller's width apart give away
/// where it is, the line from one to the other is its sideways axis (which way it is turned,
/// and how far it is rolled to a side), and the way the hands point is how far its front is
/// tilted up or down.
///
/// Everything is in the headset's space: metres, +X right, +Y up, -Z forward.
namespace HeldPad {
struct Vec {
float x{}, y{}, z{};
};
struct Quat {
float x{}, y{}, z{}, w{1.0f};
};
struct Hands {
Vec left_palm;
Vec right_palm;
/// The way each hand points, from the wrist to the knuckles (along the middle finger's
/// bone in the palm), each of length one: if the headset can tell.
bool pointing_known{};
Vec left_pointing;
Vec right_pointing;
};
struct Pose {
/// The middle of the gamepad.
Vec position;
/// The hands are level enough for the gamepad's heading and attitude to be told.
bool turned_known{};
/// Heading: 0 when the gamepad points straight ahead (-Z), positive to the left.
float yaw{};
/// How far the hands point above level, in radians: measured, or `assumed_pitch` where
/// the headset cannot tell.
float pitch{};
bool pitch_measured{};
/// The complete attitude: -Z is the way the gamepad's front points, +Y out of its face.
Quat orientation;
};
inline Vec Sub(const Vec& a, const Vec& b) {
return {a.x - b.x, a.y - b.y, a.z - b.z};
}
inline float Dot(const Vec& a, const Vec& b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
inline Vec Cross(const Vec& a, const Vec& b) {
return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x};
}
inline float Length(const Vec& a) {
return std::sqrt(Dot(a, a));
}
inline Vec Scale(const Vec& a, float by) {
return {a.x * by, a.y * by, a.z * by};
}
inline Vec Rotate(const Quat& q, const Vec& v) {
const Vec u{q.x, q.y, q.z};
const Vec t = Scale(Cross(u, v), 2.0f);
const Vec s = Cross(u, t);
return {v.x + q.w * t.x + s.x, v.y + q.w * t.y + s.y, v.z + q.w * t.z + s.z};
}
/// The rotation that takes +X to `right`, +Y to `up` and +Z to `back`: three directions of
/// length one, each at a right angle to the others.
inline Quat FromAxes(const Vec& right, const Vec& up, const Vec& back) {
const float trace = right.x + up.y + back.z;
Quat q;
if (trace > 0.0f) {
const float s = std::sqrt(trace + 1.0f) * 2.0f;
q = {(up.z - back.y) / s, (back.x - right.z) / s, (right.y - up.x) / s, 0.25f * s};
} else if (right.x > up.y && right.x > back.z) {
const float s = std::sqrt(1.0f + right.x - up.y - back.z) * 2.0f;
q = {0.25f * s, (up.x + right.y) / s, (back.x + right.z) / s, (up.z - back.y) / s};
} else if (up.y > back.z) {
const float s = std::sqrt(1.0f + up.y - right.x - back.z) * 2.0f;
q = {(up.x + right.y) / s, 0.25f * s, (back.y + up.z) / s, (back.x - right.z) / s};
} else {
const float s = std::sqrt(1.0f + back.z - right.x - up.y) * 2.0f;
q = {(back.x + right.z) / s, (back.y + up.z) / s, 0.25f * s, (right.y - up.x) / s};
}
const float length = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
return {q.x / length, q.y / length, q.z / length, q.w / length};
}
/// Part of the way from one attitude to another: `blend` of it, 0 to 1.
inline Quat Towards(const Quat& from, const Quat& to, float blend) {
// (The same attitude has two quaternions: the nearer one.)
const float side = from.x * to.x + from.y * to.y + from.z * to.z + from.w * to.w < 0.0f
? -1.0f
: 1.0f;
Quat q{from.x + (to.x * side - from.x) * blend, from.y + (to.y * side - from.y) * blend,
from.z + (to.z * side - from.z) * blend, from.w + (to.w * side - from.w) * blend};
const float length = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
if (!(length > 1e-6f)) {
return to;
}
return {q.x / length, q.y / length, q.z / length, q.w / length};
}
/// Hands further apart than a gamepad is wide, or closer together, are not holding one:
/// nothing then. `tilt` is added to how far the hands point up, for where a gamepad's front
/// points by more or less than the hands around it do; `assumed_pitch` is how far up the
/// hands are taken to point while the headset cannot tell.
inline std::optional<Pose> Locate(const Hands& hands, float tilt, float assumed_pitch) {
const Vec across = Sub(hands.right_palm, hands.left_palm);
const float distance = Length(across);
if (!(distance > 0.05f && distance < 0.32f)) {
return std::nullopt;
}
Pose pose;
pose.position = {(hands.left_palm.x + hands.right_palm.x) * 0.5f,
(hands.left_palm.y + hands.right_palm.y) * 0.5f,
(hands.left_palm.z + hands.right_palm.z) * 0.5f};
pose.pitch = assumed_pitch;
const float level = std::hypot(across.x, across.z);
if (level > 0.6f * distance) {
// The line from the left to the right palm is the gamepad's sideways axis; its
// heading follows from that.
pose.turned_known = true;
pose.yaw = std::atan2(-across.z, across.x);
// The palms close around the grips, which sit behind and below the middle of the
// gamepad.
pose.position.x += -std::sin(pose.yaw) * 0.035f;
pose.position.z += -std::cos(pose.yaw) * 0.035f;
const Vec right = Scale(across, 1.0f / distance);
// Level and at a right angle to that line: where the front points if it is neither
// tilted up nor down. What is up for a gamepad held like that goes with it.
const Vec level_front_long = Cross({0.0f, 1.0f, 0.0f}, right);
const Vec level_front = Scale(level_front_long, 1.0f / Length(level_front_long));
const Vec level_up = Cross(right, level_front);
if (hands.pointing_known) {
// Both hands point inwards as much as forwards: what they agree on, without
// what goes along the line between them, is the way the gamepad points.
Vec pointing{hands.left_pointing.x + hands.right_pointing.x,
hands.left_pointing.y + hands.right_pointing.y,
hands.left_pointing.z + hands.right_pointing.z};
pointing = Sub(pointing, Scale(right, Dot(pointing, right)));
if (Length(pointing) > 0.5f) {
pose.pitch = std::atan2(Dot(pointing, level_up), Dot(pointing, level_front));
pose.pitch_measured = true;
}
}
const float pitch = pose.pitch + tilt;
const Vec front{level_front.x * std::cos(pitch) + level_up.x * std::sin(pitch),
level_front.y * std::cos(pitch) + level_up.y * std::sin(pitch),
level_front.z * std::cos(pitch) + level_up.z * std::sin(pitch)};
pose.orientation = FromAxes(right, Cross(right, front), Scale(front, -1.0f));
}
pose.position.y += 0.015f;
return pose;
}
} // namespace HeldPad
-55
View File
@@ -1,55 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "log.h"
#include <cstdarg>
#include <cstdio>
#include <ctime>
#include <mutex>
#include <string>
namespace {
std::mutex g_mutex;
FILE* g_file = nullptr;
} // namespace
void SetLogFile(const char* path) {
std::scoped_lock lock{g_mutex};
if (g_file != nullptr) {
std::fclose(g_file);
}
// "e": the emulator process must not inherit the file.
g_file = std::fopen(path, "we");
}
void HostLog(int priority, const char* format, ...) {
va_list args;
va_start(args, format);
va_list measure;
va_copy(measure, args);
const int length = std::vsnprintf(nullptr, 0, format, measure);
va_end(measure);
std::string text(length > 0 ? static_cast<size_t>(length) : 0, '\0');
if (length > 0) {
std::vsnprintf(text.data(), text.size() + 1, format, args);
}
va_end(args);
__android_log_write(priority, LOG_TAG, text.c_str());
std::scoped_lock lock{g_mutex};
if (g_file == nullptr) {
return;
}
timespec now{};
clock_gettime(CLOCK_REALTIME, &now);
tm local{};
localtime_r(&now.tv_sec, &local);
const char level = priority >= ANDROID_LOG_ERROR ? 'E' : priority >= ANDROID_LOG_WARN ? 'W' : 'I';
std::fprintf(g_file, "%02d:%02d:%02d.%03ld %c %s\n", local.tm_hour, local.tm_min, local.tm_sec,
now.tv_nsec / 1'000'000, level, text.c_str());
// A session can end by the app being killed.
std::fflush(g_file);
}
-16
View File
@@ -1,16 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <android/log.h>
#define LOG_TAG "AstroVR"
/// Writes a line to the system log and, once SetLogFile was called, to that file too. The system
/// log is a ring shared with everything else on the headset: by the time somebody asks what
/// happened in a session, it has moved on.
void HostLog(int priority, const char* format, ...) __attribute__((format(printf, 2, 3)));
void SetLogFile(const char* path);
#define LOGI(...) HostLog(ANDROID_LOG_INFO, __VA_ARGS__)
#define LOGW(...) HostLog(ANDROID_LOG_WARN, __VA_ARGS__)
#define LOGE(...) HostLog(ANDROID_LOG_ERROR, __VA_ARGS__)
-365
View File
@@ -1,365 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// JNI surface of com.astrobotquest.vrhost.MainActivity. The activity owns the lifecycle and
// everything that is easier in Java (controller events, drawing text, unpacking the runtime);
// this side owns the OpenXR session and the emulator process.
#include <algorithm>
#include <atomic>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <jni.h>
#include <sys/resource.h>
#include "core_process.h"
#include "log.h"
#include "pad_router.h"
#include "xr_host.h"
namespace {
struct App {
JavaVM* vm{};
jobject activity{};
CoreProcess core;
StatusImage status;
XrHostStatus xr_status;
std::thread xr_thread;
std::atomic<bool> quit{};
std::atomic<uint32_t> recenter_requests{};
/// What the game is played with: a gamepad the activity hears of, the headset's own
/// controllers the session reads, or both.
PadRouter pads;
};
std::unique_ptr<App> g_app;
std::string ToString(JNIEnv* env, jstring text) {
if (text == nullptr) {
return {};
}
const char* chars = env->GetStringUTFChars(text, nullptr);
std::string result{chars != nullptr ? chars : ""};
env->ReleaseStringUTFChars(text, chars);
return result;
}
std::vector<std::string> ToStrings(JNIEnv* env, jobjectArray array) {
std::vector<std::string> result;
const jsize count = array != nullptr ? env->GetArrayLength(array) : 0;
for (jsize i = 0; i < count; ++i) {
const auto element = static_cast<jstring>(env->GetObjectArrayElement(array, i));
result.push_back(ToString(env, element));
env->DeleteLocalRef(element);
}
return result;
}
} // namespace
extern "C" {
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeStartXr(
JNIEnv* env, jobject activity, jfloat refresh_rate, jint eye_width, jint eye_height,
jboolean track_hands, jint sharpen, jboolean cubic, jboolean show_stats, jfloat predict_ms,
jint dynamic_resolution, jboolean cpu_boost, jboolean own_controllers, jint pad_hand,
jfloat pad_tilt, jboolean rumble, jboolean stick_touchpad) {
if (g_app) {
return;
}
g_app = std::make_unique<App>();
env->GetJavaVM(&g_app->vm);
g_app->activity = env->NewGlobalRef(activity);
g_app->pads.SetStickTouchpad(stick_touchpad == JNI_TRUE);
g_app->pads.SetPadHand(pad_hand);
g_app->pads.SetNotice([](const char* text) { LOGI("%s", text); });
g_app->pads.SetSink([app = g_app.get()](const PadRouter::Output& output) {
app->core.SetPad(output.pad);
app->core.SetBlowing(output.blowing);
});
XrHostOptions options;
options.refresh_rate = refresh_rate;
options.eye_width = static_cast<uint32_t>(eye_width);
options.eye_height = static_cast<uint32_t>(eye_height);
options.track_hands = track_hands == JNI_TRUE;
options.sharpen = sharpen;
options.cubic = cubic == JNI_TRUE;
options.show_stats = show_stats == JNI_TRUE;
options.predict_ms = predict_ms;
options.dynamic_resolution = dynamic_resolution != 0;
options.follow_layer_size = dynamic_resolution == 2;
options.cpu_boost = cpu_boost == JNI_TRUE;
options.own_controllers = own_controllers == JNI_TRUE;
options.pad_hand = pad_hand == 0 ? 0 : 1;
options.pad_tilt = pad_tilt;
options.rumble = rumble == JNI_TRUE;
g_app->xr_thread = std::thread{[options] {
App& app = *g_app;
JNIEnv* thread_env = nullptr;
app.vm->AttachCurrentThread(&thread_env, nullptr);
// This thread has little to do, but it has to do it every refresh: it must not wait
// for the emulator's threads, which outnumber the processor cores.
::setpriority(PRIO_PROCESS, 0, -16);
RunXrHost(app.vm, app.activity, app.core, app.pads, app.status, options, app.xr_status,
app.quit, app.recenter_requests);
LOGI("XR thread finished");
if (!app.quit) {
// The system ended the session (the user quit from its menu), or there never was
// one: without a headset to show it in there is no point in the game running on.
const jclass activity_class = thread_env->GetObjectClass(app.activity);
const jmethodID ended =
thread_env->GetMethodID(activity_class, "onSessionEnded", "()V");
if (ended != nullptr) {
thread_env->CallVoidMethod(app.activity, ended);
}
if (thread_env->ExceptionCheck()) {
thread_env->ExceptionClear();
}
}
app.vm->DetachCurrentThread();
}};
}
JNIEXPORT jboolean JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeStartCore(
JNIEnv* env, jobject, jstring loader, jstring runtime_root, jstring storage_root, jstring game,
jstring log_file, jobjectArray extra_args, jobjectArray extra_env) {
if (!g_app) {
return JNI_FALSE;
}
CoreLaunch launch;
launch.loader = ToString(env, loader);
launch.runtime_root = ToString(env, runtime_root);
launch.storage_root = ToString(env, storage_root);
launch.game = ToString(env, game);
launch.log_file = ToString(env, log_file);
launch.extra_args = ToStrings(env, extra_args);
// NAME=value pairs.
for (const std::string& entry : ToStrings(env, extra_env)) {
const size_t equals = entry.find('=');
if (equals != std::string::npos && equals > 0) {
launch.extra_env.emplace_back(entry.substr(0, equals), entry.substr(equals + 1));
}
}
return g_app->core.Start(launch) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeStop(JNIEnv* env, jobject) {
if (!g_app) {
return;
}
g_app->quit = true;
g_app->core.Stop();
if (g_app->xr_thread.joinable()) {
g_app->xr_thread.join();
}
env->DeleteGlobalRef(g_app->activity);
g_app.reset();
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetFieldOfView(
JNIEnv*, jobject, jint percent) {
if (g_app) {
g_app->xr_status.reduced_fov = percent < 100;
}
}
JNIEXPORT jfloatArray JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeHeadsetFov(
JNIEnv* env, jobject) {
if (!g_app) {
return nullptr;
}
std::scoped_lock lock{g_app->xr_status.optics_mutex};
const auto& fov = g_app->xr_status.headset_fov;
if (fov[0] <= 0.0f) {
return nullptr;
}
const jfloatArray result = env->NewFloatArray(4);
if (result != nullptr) {
env->SetFloatArrayRegion(result, 0, 4, fov.data());
}
return result;
}
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeCoreState(JNIEnv*,
jobject) {
return g_app ? static_cast<jint>(g_app->core.GetState()) : 0;
}
JNIEXPORT jstring JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeCoreMessage(JNIEnv* env,
jobject) {
return env->NewStringUTF(g_app ? g_app->core.GetMessage().c_str() : "");
}
JNIEXPORT jlong JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeFrameCount(JNIEnv*,
jobject) {
return g_app ? static_cast<jlong>(g_app->core.GetPresentedFrames()) : 0;
}
/// `pixels` is a direct buffer of StatusImage::Width x Height RGBA pixels, top row first.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetStatusImage(
JNIEnv* env, jobject, jobject pixels) {
if (!g_app) {
return;
}
const auto* data = static_cast<const uint8_t*>(env->GetDirectBufferAddress(pixels));
const jlong size = env->GetDirectBufferCapacity(pixels);
const jlong expected = jlong{StatusImage::Width} * StatusImage::Height * 4;
if (data == nullptr || size < expected) {
return;
}
std::scoped_lock lock{g_app->status.mutex};
g_app->status.pixels.assign(data, data + expected);
++g_app->status.version;
}
/// What the gamepad says: buttons in the PS4 pad's bit layout, sticks from -1 to 1 (to the
/// right and towards the player), triggers from 0 to 1, and the finger on its own touchpad
/// if it has one (0 to 1 from the pad's left and from its far edge).
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeInput(
JNIEnv*, jobject, jint buttons, jfloat left_x, jfloat left_y, jfloat right_x, jfloat right_y,
jfloat left_trigger, jfloat right_trigger, jboolean touch_down, jfloat touch_x,
jfloat touch_y) {
if (!g_app) {
return;
}
GamepadState state;
state.buttons = static_cast<uint32_t>(buttons);
state.left_x = left_x;
state.left_y = left_y;
state.right_x = right_x;
state.right_y = right_y;
state.left_trigger = left_trigger;
state.right_trigger = right_trigger;
state.touch_down = touch_down == JNI_TRUE;
state.touch_x = touch_x;
state.touch_y = touch_y;
g_app->pads.SetGamepad(state, PadRouter::Now());
}
/// Gyroscope in rad/s and accelerometer in m/s², in the controller's frame.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeMotion(
JNIEnv*, jobject, jfloat gx, jfloat gy, jfloat gz, jfloat ax, jfloat ay, jfloat az) {
if (!g_app) {
return;
}
const float gyro[3]{gx, gy, gz};
const float accel[3]{ax, ay, az};
g_app->pads.SetGamepadMotion(gyro, accel, PadRouter::Now());
}
/// A gamepad is there to play with (again, or another one than before), or none is any more.
/// `touchpad`: it has a touchpad of its own.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetGamepad(
JNIEnv*, jobject, jboolean connected, jboolean touchpad) {
if (g_app) {
g_app->pads.SetGamepadConnected(connected == JNI_TRUE, touchpad == JNI_TRUE,
PadRouter::Now());
}
}
/// How often OPTIONS was pressed in the game so far.
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeOptionsPresses(JNIEnv*,
jobject) {
return g_app ? static_cast<jint>(g_app->pads.Look().options_presses) : 0;
}
/// Shows the panel over the game although the game has a picture (for what the player
/// should read while they play), or no longer.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeShowPanel(JNIEnv*, jobject,
jboolean show) {
if (g_app) {
g_app->xr_status.show_panel = show == JNI_TRUE;
}
}
/// The host's own menu is over (or not yet): what the player presses is the game's.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetPlaying(
JNIEnv*, jobject, jboolean playing) {
if (g_app) {
g_app->pads.SetPlaying(playing == JNI_TRUE, PadRouter::Now());
}
}
/// What the game is played with: 0 nothing, 1 the gamepad, 2 the headset's own controllers.
/// 4 is added while those are in the player's hands, 8 where what the game is played with
/// has no touchpad and buttons do its gestures.
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeController(JNIEnv*,
jobject) {
if (!g_app) {
return 0;
}
const PadRouter::View view = g_app->pads.Look();
return static_cast<jint>(view.source) | (view.touch_present ? 4 : 0) |
(view.gesture_buttons ? 8 : 0);
}
/// The player chose with it in the host's menu: 1 the gamepad, 2 the headset's controllers.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeUseController(
JNIEnv*, jobject, jint which) {
if (g_app) {
g_app->pads.Use(which == 2 ? PadRouter::Source::Touch : PadRouter::Source::Gamepad,
PadRouter::Now());
}
}
/// What the headset's own controllers say to the host's menu: 1 a stick held to the left, 2
/// to the right, 4 what confirms (A or a trigger).
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeTouchMenu(JNIEnv*,
jobject) {
return g_app ? static_cast<jint>(g_app->pads.TouchMenu()) : 0;
}
/// What the game asks of the controller: see CoreProcess::GetPadFeedback for the layout.
JNIEXPORT jlong JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativePadFeedback(JNIEnv*,
jobject) {
return g_app ? static_cast<jlong>(g_app->core.GetPadFeedback()) : 0;
}
/// The player asks for the view to be reset (see XrRecenter in xr_host.h for what of it).
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeRecenter(JNIEnv*, jobject,
jint what) {
if (g_app) {
g_app->recenter_requests.fetch_or(static_cast<uint32_t>(what));
}
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetMicrophone(
JNIEnv*, jobject, jboolean enabled, jfloat gain) {
if (g_app) {
g_app->core.SetMicrophone(enabled == JNI_TRUE, gain);
}
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetLogFile(JNIEnv* env,
jclass,
jstring path) {
SetLogFile(ToString(env, path).c_str());
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeLog(JNIEnv* env, jclass,
jint priority,
jstring message) {
HostLog(priority, "%s", ToString(env, message).c_str());
}
/// Bit 0: the headset session is running. Bit 1: the hands holding the controller are seen.
/// Bit 2: the one of the headset's own controllers that is the controller in the game is.
/// Bits 8 to 15: the display's refresh rate in Hz.
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeXrStatus(JNIEnv*,
jobject) {
if (!g_app) {
return 0;
}
const int refresh_rate =
std::clamp(static_cast<int>(g_app->xr_status.refresh_rate.load() + 0.5f), 0, 255);
return (g_app->xr_status.session_running ? 1 : 0) | (g_app->xr_status.hands_tracked ? 2 : 0) |
(g_app->xr_status.controller_tracked ? 4 : 0) | (refresh_rate << 8);
}
} // extern "C"
-587
View File
@@ -1,587 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <functional>
#include <mutex>
#include <utility>
#include "pad_state.h"
// From the emulator (src/input): the right stick as a finger on the touchpad, and buttons
// that do what a finger does there.
#include "pad_gestures.h"
#include "stick_finger.h"
#include "view_turn.h"
/// PS4 pad button bits (ORBIS_PAD_BUTTON_*).
namespace PadButton {
inline constexpr uint32_t L3 = 0x2, R3 = 0x4, Options = 0x8;
inline constexpr uint32_t Up = 0x10, Right = 0x20, Down = 0x40, Left = 0x80;
inline constexpr uint32_t L2 = 0x100, R2 = 0x200, L1 = 0x400, R1 = 0x800;
inline constexpr uint32_t Triangle = 0x1000, Circle = 0x2000, Cross = 0x4000, Square = 0x8000;
inline constexpr uint32_t TouchPad = 0x100000;
} // namespace PadButton
/// What a gamepad says, as the system reports it. Buttons use the PS4 pad bit layout; sticks
/// go from -1 to 1, to the right and towards the player; triggers from 0 to 1.
struct GamepadState {
uint32_t buttons{};
float left_x{}, left_y{}, right_x{}, right_y{};
float left_trigger{}, right_trigger{};
/// A finger on the gamepad's own touchpad, 0 to 1 from its left and from its far edge.
bool touch_down{};
float touch_x{0.5f}, touch_y{0.5f};
};
/// What the headset's own two controllers say, as its runtime reports them: sticks from -1 to
/// 1, to the right and away from the player (the other way up than a gamepad's).
struct TouchState {
/// They are in the player's hands, and the game is what the player has in front of them.
bool present{};
bool a{}, b{}, x{}, y{}, menu{};
bool left_stick_in{}, right_stick_in{};
float left_grip{}, right_grip{};
float left_trigger{}, right_trigger{};
float left_x{}, left_y{}, right_x{}, right_y{};
};
/// Makes the one DualShock 4 the game is played with out of what the player has: a gamepad
/// paired with the headset, the headset's own two controllers, or both.
///
/// The headset's controllers are played like this: the left stick is the left stick; the
/// right one is a finger on the touchpad (as it is for gamepads without one) and pressing it
/// in presses the touchpad; A and B, under the right thumb, are cross and square, X and Y
/// circle and triangle, and X and Y together blow into the microphone; the triggers are L2
/// and R2, the grips L1 and R1, the left controller's menu button is OPTIONS. Both sticks
/// pressed in reset the view.
///
/// What the game wants done on the touchpad is also on buttons, for the headset's
/// controllers and for every gamepad that has no touchpad (see Input::PadGestures): the
/// trigger of the hand that holds the controller in the game (R2 on a gamepad) presses the
/// pad for as long as it is pulled, the grip of that hand (R1) swipes forward once, and the
/// other hand's trigger (L2) pulls back, holds, and lets go when it is let go. The game has
/// no use for those buttons in play; it is still told of them, for the menus that have.
///
/// With both a gamepad and the headset's controllers at hand, the game is played with the
/// one that was used last: a button on the other takes over. A gamepad is taken over from
/// only by what a hand cannot do without holding a controller (a button under a thumb, a
/// stick), since it is hands the headset sees while a gamepad is held.
class PadRouter {
public:
enum class Source { None, Gamepad, Touch };
/// Bits of what TakeRecenter returns (those of XrRecenter).
static constexpr uint32_t RecenterPad = 1;
static constexpr uint32_t RecenterSeat = 2;
/// Bits of what TouchMenu returns.
static constexpr uint32_t MenuLeft = 1;
static constexpr uint32_t MenuRight = 2;
static constexpr uint32_t MenuConfirm = 4;
/// How long OPTIONS is held for the view to be reset, in seconds: as on a PlayStation VR.
static constexpr double OptionsHold = 1.0;
struct Output {
PadState pad;
/// The player blows with buttons: the game is to hear it.
bool blowing{};
bool operator==(const Output&) const = default;
};
/// What the rest of the host needs to know to place the controller and to answer it.
struct View {
Source source{Source::None};
bool playing{};
bool gamepad_connected{};
/// The gamepad's own motion sensors say how it is held.
bool gamepad_motion{};
float gamepad_accel[3]{};
bool touch_present{};
/// What the game is played with has no touchpad: buttons do its gestures.
bool gesture_buttons{};
/// How often OPTIONS was pressed in the game so far.
uint32_t options_presses{};
};
/// The time everything here is told, in seconds: the same clock for every caller.
static double Now() {
return std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
/// Both are called with the router's lock held: they must not call back into it.
void SetSink(std::function<void(const Output&)> sink_) {
std::scoped_lock lock{mutex};
sink = std::move(sink_);
}
void SetNotice(std::function<void(const char*)> notice_) {
std::scoped_lock lock{mutex};
notice = std::move(notice_);
}
/// Whether the right stick moves a finger over the touchpad.
void SetStickTouchpad(bool enabled) {
std::scoped_lock lock{mutex};
stick_touchpad = enabled;
}
/// Which of the headset's own controllers is the controller in the game: 1 the right
/// one, 0 the left. Its trigger and grip are the ones that press and swipe.
void SetPadHand(int hand) {
std::scoped_lock lock{mutex};
pad_hand = hand == 0 ? 0 : 1;
}
/// The game runs, and what the player presses is meant for it. Until then the controls
/// belong to the host's own menu, and nothing of them reaches the game: neither does
/// what is still held down from there, until it is let go.
void SetPlaying(bool playing_, double now) {
std::scoped_lock lock{mutex};
if (playing_ && !playing) {
held_back = Read().buttons;
}
playing = playing_;
Publish(now);
}
/// A gamepad is there (again, or another one than before), or the last one is gone.
/// `touchpad`: it has a touchpad of its own, and needs no buttons to stand in for one.
void SetGamepadConnected(bool connected, bool touchpad, double now) {
std::scoped_lock lock{mutex};
gamepad_connected = connected;
gamepad_touchpad = connected && touchpad;
// Its motion sensors, if it has any, speak up within milliseconds.
gamepad_motion = false;
if (!connected) {
gamepad = {};
}
Arbitrate(false, false);
Publish(now);
}
void SetGamepad(const GamepadState& state, double now) {
std::scoped_lock lock{mutex};
const bool used = (state.buttons & ~gamepad.buttons) != 0 ||
(state.touch_down && !gamepad.touch_down) ||
Pushed(state.left_x, state.left_y, gamepad.left_x, gamepad.left_y) ||
Pushed(state.right_x, state.right_y, gamepad.right_x, gamepad.right_y) ||
Pulled(state.left_trigger, gamepad.left_trigger) ||
Pulled(state.right_trigger, gamepad.right_trigger);
gamepad = state;
Arbitrate(used, false);
Publish(now);
}
/// Gyroscope in rad/s and accelerometer in m/s², in the gamepad's own frame.
void SetGamepadMotion(const float gyro[3], const float accel[3], double now) {
std::scoped_lock lock{mutex};
gamepad_motion = true;
std::copy(gyro, gyro + 3, gamepad_gyro);
std::copy(accel, accel + 3, gamepad_accel);
Publish(now);
}
/// To be called for every picture the headset shows: it is also what keeps time for
/// buttons that are held and for the stick's finger.
void SetTouch(const TouchState& state, double now) {
std::scoped_lock lock{mutex};
const TouchState next = state.present ? state : TouchState{};
// (Triggers and grips are left out: a pinch of the fingers must never count.)
const bool used =
(next.a && !touch.a) || (next.b && !touch.b) || (next.x && !touch.x) ||
(next.y && !touch.y) || (next.left_stick_in && !touch.left_stick_in) ||
(next.right_stick_in && !touch.right_stick_in) ||
Pushed(next.left_x, next.left_y, touch.left_x, touch.left_y) ||
Pushed(next.right_x, next.right_y, touch.right_x, touch.right_y);
touch = next;
Arbitrate(false, used);
Publish(now);
}
/// Without the headset's controllers: time has passed.
void Tick(double now) {
std::scoped_lock lock{mutex};
Publish(now);
}
/// The player chose with this one in the host's menu: it is what they hold.
void Use(Source wanted, double now) {
std::scoped_lock lock{mutex};
Arbitrate(wanted == Source::Gamepad, wanted == Source::Touch);
Publish(now);
}
View Look() {
std::scoped_lock lock{mutex};
View view;
view.source = source;
view.playing = playing;
view.gamepad_connected = gamepad_connected;
view.gamepad_motion = gamepad_motion;
std::copy(gamepad_accel, gamepad_accel + 3, view.gamepad_accel);
view.touch_present = touch.present;
view.gesture_buttons =
source == Source::Touch || (source == Source::Gamepad && !gamepad_touchpad);
view.options_presses = options_presses;
return view;
}
/// The resets of the view the player has asked for with buttons since the last call.
uint32_t TakeRecenter() {
std::scoped_lock lock{mutex};
return std::exchange(recenter, 0u);
}
/// The steps the player has asked the view to be turned by since the last call: to the
/// right if positive (see Input::ViewTurn).
int TakeTurn() {
std::scoped_lock lock{mutex};
return std::exchange(turns, 0);
}
/// What the headset's controllers say to the host's own menu: a stick held to a side, or
/// what confirms (A, or a trigger as everywhere else in the headset).
uint32_t TouchMenu() {
std::scoped_lock lock{mutex};
if (!touch.present) {
return 0;
}
const float x = std::abs(touch.left_x) >= std::abs(touch.right_x) ? touch.left_x
: touch.right_x;
return (x < -0.55f ? MenuLeft : 0) | (x > 0.55f ? MenuRight : 0) |
(touch.a || touch.left_trigger > 0.6f || touch.right_trigger > 0.6f ? MenuConfirm
: 0);
}
private:
/// A stick that was near its centre and is well away from it now.
static bool Pushed(float x, float y, float before_x, float before_y) {
return std::hypot(x, y) > 0.6f && std::hypot(before_x, before_y) <= 0.6f;
}
static bool Pulled(float value, float before) {
return value > 0.5f && before <= 0.5f;
}
void Note(const char* text) {
if (notice) {
notice(text);
}
}
/// Decides which of the two the game is played with.
void Arbitrate(bool gamepad_used, bool touch_used) {
Source wanted = source;
if (gamepad_used && gamepad_connected) {
wanted = Source::Gamepad;
}
if (touch_used && touch.present) {
wanted = Source::Touch;
}
if (wanted == Source::Gamepad && !gamepad_connected) {
wanted = Source::None;
}
if (wanted == Source::None) {
// Nothing was chosen: a gamepad if there is one, as before the headset's own
// controllers could play; else those, as soon as they are picked up.
wanted = gamepad_connected ? Source::Gamepad
: touch.present ? Source::Touch : Source::None;
}
if (wanted == source) {
return;
}
source = wanted;
// What the other one was doing on the touchpad is over.
stick_finger.Reset();
gestures.Reset();
view_turn.Reset();
options_since = -1.0;
options_fired = false;
view_reset_held = false;
Note(source == Source::Touch
? "the game is played with the headset's own controllers now"
: source == Source::Gamepad ? "the game is played with the gamepad now"
: "there is no controller to play the game with");
}
static uint8_t Stick(float value) {
return static_cast<uint8_t>(
std::clamp(static_cast<int>(std::lround(128.0f + value * 127.0f)), 0, 255));
}
static uint8_t Trigger(float value) {
return static_cast<uint8_t>(
std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f));
}
/// What the controller the game is played with says right now, as a gamepad would say it.
struct Reading {
uint32_t buttons{};
float left_x{}, left_y{}, right_x{}, right_y{};
float left_trigger{}, right_trigger{};
/// A real finger on a real touchpad.
bool finger{};
float finger_x{0.5f}, finger_y{0.5f};
bool view_reset{};
bool blow{};
/// The buttons that do the touchpad's gestures, and which button each of them is.
Input::PadGestures::Controls gestures;
uint32_t press_button{}, swipe_button{}, pull_button{};
/// The button that, held, has the right stick turn the view (see Input::ViewTurn).
uint32_t turn_button{};
};
Reading Read() const {
Reading read;
if (source == Source::Touch) {
const TouchState& t = touch;
const auto add = [&](bool down, uint32_t button) {
if (down) {
read.buttons |= button;
}
};
add(t.a, PadButton::Cross);
add(t.b, PadButton::Square);
// The left controller's two buttons together are not circle and triangle: they
// blow into the microphone, for as long as they are held.
read.blow = t.x && t.y;
add(t.x && !read.blow, PadButton::Circle);
add(t.y && !read.blow, PadButton::Triangle);
add(t.menu, PadButton::Options);
add(t.left_grip > 0.5f, PadButton::L1);
add(t.right_grip > 0.5f, PadButton::R1);
// Both sticks pressed in is not something a game is played with: it resets the
// view, for which a gamepad has its PS button.
read.view_reset = t.left_stick_in && t.right_stick_in;
add(t.left_stick_in && !read.view_reset, PadButton::L3);
add(t.right_stick_in && !read.view_reset, PadButton::TouchPad);
// (A trigger that is not quite at rest is not pulled: any pull counts as the
// button.)
read.left_trigger = t.left_trigger < 0.12f ? 0.0f : t.left_trigger;
read.right_trigger = t.right_trigger < 0.12f ? 0.0f : t.right_trigger;
add(read.left_trigger > 0.0f, PadButton::L2);
add(read.right_trigger > 0.0f, PadButton::R2);
read.left_x = t.left_x;
read.left_y = -t.left_y;
read.right_x = t.right_x;
read.right_y = -t.right_y;
// The hand that holds the controller in the game presses and swipes, the other
// one pulls.
const bool right = pad_hand != 0;
read.gestures.press = (right ? t.right_trigger : t.left_trigger) > 0.5f;
read.gestures.swipe = (right ? t.right_grip : t.left_grip) > 0.5f;
read.gestures.pull = (right ? t.left_trigger : t.right_trigger) > 0.5f;
read.press_button = right ? PadButton::R2 : PadButton::L2;
read.swipe_button = right ? PadButton::R1 : PadButton::L1;
read.pull_button = right ? PadButton::L2 : PadButton::R2;
// (The grip that is left without a meaning.)
read.turn_button = right ? PadButton::L1 : PadButton::R1;
} else {
const GamepadState& g = gamepad;
read.buttons = g.buttons;
read.left_x = g.left_x;
read.left_y = g.left_y;
read.right_x = g.right_x;
read.right_y = g.right_y;
read.left_trigger = g.left_trigger;
read.right_trigger = g.right_trigger;
read.finger = g.touch_down;
read.finger_x = g.touch_x;
read.finger_y = g.touch_y;
read.turn_button = PadButton::L1;
if (!gamepad_touchpad) {
read.gestures.press = g.right_trigger > 0.5f;
read.gestures.swipe = (g.buttons & PadButton::R1) != 0;
read.gestures.pull = g.left_trigger > 0.5f;
read.press_button = PadButton::R2;
read.swipe_button = PadButton::R1;
read.pull_button = PadButton::L2;
}
}
return read;
}
/// What the game is given now, and what the buttons mean besides (resets of the view).
Output Compose(double now) {
Output out;
if (!playing) {
return out;
}
Reading read = Read();
uint32_t buttons = read.buttons;
if (source != Source::Touch && gamepad_motion) {
out.pad.has_motion = true;
std::copy(gamepad_gyro, gamepad_gyro + 3, out.pad.gyro);
std::copy(gamepad_accel, gamepad_accel + 3, out.pad.accel);
}
// What was held down when the game got the controls is not the game's.
held_back &= buttons;
buttons &= ~held_back;
if ((held_back & PadButton::L2) != 0) {
read.left_trigger = 0.0f;
}
if ((held_back & PadButton::R2) != 0) {
read.right_trigger = 0.0f;
}
read.gestures.press = read.gestures.press && (held_back & read.press_button) == 0;
read.gestures.swipe = read.gestures.swipe && (held_back & read.swipe_button) == 0;
read.gestures.pull = read.gestures.pull && (held_back & read.pull_button) == 0;
// The touchpad. A real finger on a real one comes first; then what buttons do on a
// controller that has none (the pad pressed, a swipe forward, a pull back that is
// let go); then the right stick, for which this title has no use, as a finger.
bool touching = read.finger;
float touch_x = read.finger_x;
float touch_y = read.finger_y;
const Input::PadGestures::Touch made = gestures.Update(now, read.gestures);
// A button the game has no use for in play, held: each flick of the right stick to a
// side turns the view a step that way, for players who cannot turn round where they
// sit. The stick is no finger meanwhile, and no stick to the game either.
const bool turning = (buttons & read.turn_button) != 0;
if (const int steps = view_turn.Update(turning, read.right_x); steps != 0) {
turns += steps;
Note(steps > 0 ? "the view turns a step to the right"
: "the view turns a step to the left");
}
if (turning) {
read.right_x = 0.0f;
read.right_y = 0.0f;
}
if (read.finger) {
// (The stick's finger is not back on the pad the moment the real one lifts.)
stick_finger.Reset();
} else if (made.down) {
touching = true;
touch_x = made.x;
touch_y = made.y;
if (made.pressed) {
buttons |= PadButton::TouchPad;
}
stick_finger.Reset();
} else if (turning) {
stick_finger.Reset();
} else if (stick_touchpad) {
const Input::StickFinger::Touch dragged =
stick_finger.Update(now, read.right_x, read.right_y);
if (dragged.down) {
touching = true;
touch_x = dragged.x;
touch_y = dragged.y;
}
}
if (source == Source::Touch && stick_touchpad) {
read.right_x = 0.0f;
read.right_y = 0.0f;
}
if (!touching && (buttons & PadButton::TouchPad) != 0) {
// A touchpad cannot be pressed without touching it: in its middle, where a
// button or a stick pressed in stands in for the press.
touching = true;
touch_x = 0.5f;
touch_y = 0.5f;
}
if (touching) {
sent_touch_x = static_cast<uint16_t>(
std::clamp(static_cast<int>(std::lround(touch_x * (TouchWidth - 1))), 0,
TouchWidth - 1));
sent_touch_y = static_cast<uint16_t>(
std::clamp(static_cast<int>(std::lround(touch_y * (TouchHeight - 1))), 0,
TouchHeight - 1));
}
out.pad.buttons = buttons;
out.pad.left_x = Stick(read.left_x);
out.pad.left_y = Stick(read.left_y);
out.pad.right_x = Stick(read.right_x);
out.pad.right_y = Stick(read.right_y);
out.pad.left_trigger = Trigger(read.left_trigger);
out.pad.right_trigger = Trigger(read.right_trigger);
out.pad.touch_down = touching;
// (A finger that lifts does so from where it was.)
out.pad.touch_x = sent_touch_x;
out.pad.touch_y = sent_touch_y;
out.blowing = read.blow;
if ((buttons & PadButton::Cross) != 0 && !seat_taken) {
// The first press of X is the player settled in, controller in hand, looking at
// the game: that, and not where they were when the app started, is their seat.
seat_taken = true;
recenter |= RecenterSeat;
Note("first press of X: the player's seat is where they are now");
}
if ((buttons & PadButton::Options) == 0) {
options_since = -1.0;
options_fired = false;
} else if (options_since < 0.0) {
options_since = now;
++options_presses;
} else if (!options_fired && now - options_since >= OptionsHold) {
// What a PlayStation VR does when OPTIONS is held, and what the game tells its
// players to do when the view is off. The game still sees the button.
options_fired = true;
recenter |= RecenterPad | RecenterSeat;
Note("OPTIONS held: view reset");
}
if (read.view_reset && !view_reset_held) {
recenter |= RecenterPad | RecenterSeat;
Note("both sticks pressed in: view reset");
}
view_reset_held = read.view_reset;
return out;
}
void Publish(double now) {
const Output out = Compose(now);
if (published && out == last) {
return;
}
published = true;
last = out;
if (sink) {
sink(out);
}
}
// The touchpad as the emulator takes it: the resolution of a DualSense's.
static constexpr int TouchWidth = 1920;
static constexpr int TouchHeight = 1080;
std::mutex mutex;
std::function<void(const Output&)> sink;
std::function<void(const char*)> notice;
bool stick_touchpad{true};
int pad_hand{1};
bool playing{};
Source source{Source::None};
bool gamepad_connected{};
bool gamepad_touchpad{};
GamepadState gamepad;
bool gamepad_motion{};
float gamepad_gyro[3]{};
float gamepad_accel[3]{0.0f, 9.81f, 0.0f};
TouchState touch;
Input::StickFinger stick_finger;
Input::PadGestures gestures;
Input::ViewTurn view_turn;
int turns{};
uint32_t options_presses{};
uint32_t held_back{};
uint16_t sent_touch_x{TouchWidth / 2};
uint16_t sent_touch_y{TouchHeight / 2};
bool seat_taken{};
double options_since{-1.0};
bool options_fired{};
bool view_reset_held{};
uint32_t recenter{};
bool published{};
Output last;
};
-18
View File
@@ -1,18 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <cstdint>
/// What the emulated DualShock 4 looks like right now. Buttons use the PS4 pad bit layout.
struct PadState {
uint32_t buttons{};
uint8_t left_x{128}, left_y{128}, right_x{128}, right_y{128};
uint8_t left_trigger{}, right_trigger{};
bool touch_down{};
uint16_t touch_x{}, touch_y{};
bool has_motion{};
float gyro[3]{};
float accel[3]{0.0f, 9.81f, 0.0f};
bool operator==(const PadState&) const = default;
};
-708
View File
@@ -1,708 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Everything the host does with the emulator core except showing the result in the headset:
// starting it, the control socket (controller input), the frame buffers it shares with the core
// and the GL path that reads them. It runs from the SandboxShell instrumentation, so it needs
// nobody wearing the headset, and leaves pictures of what a headset would have been shown.
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
#include <jni.h>
#include <sys/stat.h>
#include <zlib.h>
#include "core_process.h"
#include "gl_frames.h"
#include "held_pad.h"
#include "log.h"
namespace Protocol = Core::Vr::Protocol;
namespace {
void AppendChunk(std::vector<uint8_t>& png, const char* type, const uint8_t* data, size_t size) {
const auto put32 = [&](uint32_t value) {
for (int shift = 24; shift >= 0; shift -= 8) {
png.push_back(static_cast<uint8_t>(value >> shift));
}
};
put32(static_cast<uint32_t>(size));
const size_t start = png.size();
png.insert(png.end(), type, type + 4);
png.insert(png.end(), data, data + size);
put32(static_cast<uint32_t>(crc32(0, png.data() + start, static_cast<uInt>(4 + size))));
}
/// Writes RGBA pixels whose first row is the bottom one (as glReadPixels returns them) as an
/// opaque PNG, at half size: these are for looking at, not for measuring.
bool WritePng(const std::string& path, const std::vector<uint8_t>& rgba, uint32_t width,
uint32_t height) {
const uint32_t out_width = width / 2;
const uint32_t out_height = height / 2;
std::vector<uint8_t> raw;
raw.reserve(static_cast<size_t>(out_height) * (1 + out_width * 3));
for (uint32_t y = 0; y < out_height; ++y) {
raw.push_back(0); // filter: none
const uint8_t* upper = rgba.data() + static_cast<size_t>(height - 1 - 2 * y) * width * 4;
const uint8_t* lower = upper - static_cast<size_t>(width) * 4;
for (uint32_t x = 0; x < out_width; ++x) {
for (int channel = 0; channel < 3; ++channel) {
const uint32_t sum = upper[x * 8 + channel] + upper[x * 8 + 4 + channel] +
lower[x * 8 + channel] + lower[x * 8 + 4 + channel];
raw.push_back(static_cast<uint8_t>(sum / 4));
}
}
}
uLongf compressed_size = compressBound(static_cast<uLong>(raw.size()));
std::vector<uint8_t> compressed(compressed_size);
if (compress2(compressed.data(), &compressed_size, raw.data(), static_cast<uLong>(raw.size()),
3) != Z_OK) {
return false;
}
std::vector<uint8_t> png = {0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a};
uint8_t header[13] = {};
for (int i = 0; i < 4; ++i) {
header[i] = static_cast<uint8_t>(out_width >> (24 - 8 * i));
header[4 + i] = static_cast<uint8_t>(out_height >> (24 - 8 * i));
}
header[8] = 8; // bits per channel
header[9] = 2; // RGB
AppendChunk(png, "IHDR", header, sizeof(header));
AppendChunk(png, "IDAT", compressed.data(), compressed_size);
AppendChunk(png, "IEND", nullptr, 0);
std::FILE* file = std::fopen(path.c_str(), "wb");
if (file == nullptr) {
return false;
}
const bool ok = std::fwrite(png.data(), 1, png.size(), file) == png.size();
std::fclose(file);
return ok;
}
/// Stands in for the headset's compositor, which takes the GPU away from the emulator several
/// times every display refresh: a context of its own that, so many times a second, draws and
/// waits for the GPU to be done with it. Among the settings:
/// HOST_INTERRUPTS=<per second> next to nothing is drawn each time
/// HOST_COMPOSITOR=<Hz>[,<n>[,<Mpx>]] per refresh, a pass the size of the compositor's own
/// (9 megapixels unless told otherwise) and n - 1 times
/// next to nothing (n is 5 unless told otherwise: what
/// a session shows, with one layer and nothing else of
/// the system on screen)
class Interrupter {
public:
~Interrupter() {
Stop();
}
void Start(const GlContext& main, int refreshes, int per_refresh, float megapixels) {
if (refreshes > 0 && per_refresh > 0) {
thread = std::thread{[this, display = main.display, config = main.config, refreshes,
per_refresh,
megapixels] { Run(display, config, refreshes, per_refresh, megapixels); }};
}
}
void Stop() {
quit = true;
if (thread.joinable()) {
thread.join();
}
}
uint64_t Count() const {
return count;
}
/// Milliseconds the large passes took on average since the last call.
float TakePassTime() {
const uint64_t passes = pass_count.exchange(0);
const uint64_t time = pass_microseconds.exchange(0);
return passes != 0 ? static_cast<float>(time) / static_cast<float>(passes) / 1000.0f : 0.0f;
}
private:
void Run(EGLDisplay display, EGLConfig config, int refreshes, int per_refresh,
float megapixels) {
using Clock = std::chrono::steady_clock;
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
const EGLContext context =
eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
const EGLint surface_attributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
const EGLSurface surface = eglCreatePbufferSurface(display, config, surface_attributes);
if (context == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE ||
!eglMakeCurrent(display, surface, surface, context)) {
LOGE("self test: no context for the interruptions");
return;
}
const auto make_target = [](GLsizei width, GLsizei height, GLuint& texture,
GLuint& framebuffer) {
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture,
0);
};
GLuint small = 0;
GLuint small_framebuffer = 0;
make_target(64, 64, small, small_framebuffer);
// The compositor's pass: the game's picture, both eyes, drawn to the display's size.
GLuint source = 0;
GLuint source_framebuffer = 0;
GLuint large = 0;
GLuint large_framebuffer = 0;
FrameBlitter blitter;
const auto large_height = static_cast<GLsizei>(std::sqrt(megapixels * 1e6f / 1.87f));
const auto large_width = static_cast<GLsizei>(static_cast<float>(large_height) * 1.87f);
if (megapixels > 0.0f) {
make_target(2880, 1536, source, source_framebuffer);
make_target(large_width, large_height, large, large_framebuffer);
blitter.Create();
}
const auto period = std::chrono::nanoseconds{1'000'000'000LL / refreshes};
auto refresh = Clock::now();
while (!quit) {
for (int i = 0; i < per_refresh && !quit; ++i) {
if (i == 0 && megapixels > 0.0f) {
const auto begun = Clock::now();
glBindFramebuffer(GL_FRAMEBUFFER, large_framebuffer);
blitter.Draw(source, static_cast<uint32_t>(large_width),
static_cast<uint32_t>(large_height), false, false);
glFinish();
pass_microseconds += static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(Clock::now() - begun)
.count());
++pass_count;
} else {
glBindFramebuffer(GL_FRAMEBUFFER, small_framebuffer);
glClearColor(0.0f, static_cast<float>(count % 2), 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glFinish();
}
++count;
std::this_thread::sleep_until(refresh + period * (i + 1) / per_refresh);
}
refresh += period;
}
if (megapixels > 0.0f) {
blitter.Destroy();
}
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
eglDestroySurface(display, surface);
eglDestroyContext(display, context);
}
std::thread thread;
std::atomic<bool> quit{false};
std::atomic<uint64_t> count{0};
std::atomic<uint64_t> pass_count{0};
std::atomic<uint64_t> pass_microseconds{0};
};
std::string ToString(JNIEnv* env, jstring text) {
if (text == nullptr) {
return {};
}
const char* chars = env->GetStringUTFChars(text, nullptr);
std::string result{chars != nullptr ? chars : ""};
env->ReleaseStringUTFChars(text, chars);
return result;
}
std::string RunSelfTest(const CoreLaunch& launch, const std::string& out_dir, int seconds,
uint32_t eye_width, uint32_t eye_height, const std::string& microphone) {
using Clock = std::chrono::steady_clock;
const uint32_t width = eye_width * 2;
const uint32_t height = eye_height;
std::string report;
const auto note = [&](const char* format, auto... values) {
char line[512];
std::snprintf(line, sizeof(line), format, values...);
LOGI("self test: %s", line);
report += line;
report += '\n';
};
::mkdir(out_dir.c_str(), 0700);
SetLogFile((out_dir + "/host.log").c_str());
GlContext gl;
if (!gl.Create()) {
return "no GL context\n";
}
note("GL: %s", reinterpret_cast<const char*>(glGetString(GL_RENDERER)));
CoreProcess core;
FrameBuffers frames;
FrameBlitter blitter;
// "real" listens to the headset (which hears nothing while nobody wears it, but the path
// is the one a session takes), "off" to nothing, anything else to the test signal.
core.SetMicrophone(microphone != "off", 1.0f, microphone != "real");
if (!core.Start(launch)) {
gl.Destroy();
return report + "the core did not start: " + core.GetMessage() + "\n";
}
if (!frames.Create(gl.display, width, height, core) || !blitter.Create()) {
core.Stop();
gl.Destroy();
return report + "could not create the frame buffers\n";
}
// Stands in for the compositor's image the frame is copied to.
GLuint target = 0;
GLuint framebuffer = 0;
glGenTextures(1, &target);
glBindTexture(GL_TEXTURE_2D, target);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, static_cast<GLsizei>(width),
static_cast<GLsizei>(height));
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
note("%s", "the test framebuffer is incomplete");
}
// And one of the kind the compositor's images are, for timing the copy a session makes.
GLuint display_target = 0;
GLuint display_framebuffer = 0;
glGenTextures(1, &display_target);
glBindTexture(GL_TEXTURE_2D, display_target);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_SRGB8_ALPHA8, static_cast<GLsizei>(width),
static_cast<GLsizei>(height));
glGenFramebuffers(1, &display_framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, display_framebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, display_target,
0);
note("frames are copied %s", blitter.WritesUnencoded() ? "as they are" : "through a decoder");
Interrupter interrupter;
bool fov_mask = false;
for (const auto& [name, value] : launch.extra_env) {
if (name == "HOST_FOV_MASK") {
// Exercise the session's optical-axis mask in the isolated test and its captures.
fov_mask = value == "1";
note("optical-axis FOV mask: %s", fov_mask ? "on" : "off");
} else if (name == "HOST_INTERRUPTS") {
interrupter.Start(gl, std::atoi(value.c_str()), 1, 0.0f);
note("taking the GPU away from the emulator %s times a second", value.c_str());
} else if (name == "HOST_COMPOSITOR") {
int refreshes = 0;
int per_refresh = 5;
float megapixels = 9.0f;
std::sscanf(value.c_str(), "%d,%d,%f", &refreshes, &per_refresh, &megapixels);
interrupter.Start(gl, refreshes, per_refresh, megapixels);
note("a stand-in for the compositor: %d refreshes a second, the GPU taken %d times "
"in each, once for a pass of %.1f megapixels",
refreshes, per_refresh, megapixels);
}
}
uint64_t reported_interruptions = 0;
bool view_reset = false;
// HOST_DISPLAY=<Hz> among the settings: the loop below goes round once for every refresh
// of a display of that rate, the way a session's does, tells the emulator of each, and
// counts for how many refreshes each of the game's frames stayed the newest (a game that
// draws one frame for two refreshes should have every frame shown for two).
float display_rate = 0.0f;
for (const auto& [name, value] : launch.extra_env) {
if (name == "HOST_DISPLAY") {
display_rate = std::strtof(value.c_str(), nullptr);
note("a display of %.2f refreshes a second", display_rate);
}
}
uint32_t shown_for[5]{};
uint32_t shown_since = 0;
bool something_shown = false;
// HOST_LOOK="<from second>:<degrees left>,<degrees up>;..." among the settings: where the
// head looks from when on, in place of its swaying. "8:85,22;100:0,-12" finds the first
// level of Astro Bot from the world map (the planets are picked by looking at them) and
// then looks along it.
struct Look {
float from;
float yaw;
float pitch;
};
std::vector<Look> looks;
for (const auto& [name, value] : launch.extra_env) {
if (name != "HOST_LOOK") {
continue;
}
size_t at = 0;
while (at < value.size()) {
const size_t end = std::min(value.find(';', at), value.size());
Look look{};
if (std::sscanf(value.substr(at, end - at).c_str(), "%f:%f,%f", &look.from, &look.yaw,
&look.pitch) == 3) {
looks.push_back(look);
}
at = end + 1;
}
note("the head follows %zu looks", looks.size());
}
// HOST_TURN="<second>:<steps>;...": the player asks to be turned round where they sit, so
// many steps to the right (to the left if negative), as a button held and the right stick
// flicked ask in a session.
struct Turn {
float at;
int steps;
bool done;
};
std::vector<Turn> turns;
for (const auto& [name, value] : launch.extra_env) {
if (name != "HOST_TURN") {
continue;
}
size_t at = 0;
while (at < value.size()) {
const size_t end = std::min(value.find(';', at), value.size());
Turn turn{};
if (std::sscanf(value.substr(at, end - at).c_str(), "%f:%d", &turn.at, &turn.steps) ==
2) {
turns.push_back(turn);
}
at = end + 1;
}
note("the view is turned %zu times", turns.size());
}
// HOST_PAD="<from second>:<kind>,<right>,<up>,<ahead>,<left>,<tilt>,<roll>;..." among the
// settings: what the host says of the controller from when on, the way a session does.
// Where it is in metres from the head (which this test keeps where it starts), how it is
// turned in degrees (to the left, its front up, its right side up). The kinds:
// c one of the headset's own controllers: it says everything of itself
// h hands seen around a gamepad without motion sensors: they say where it is and, as
// far as hands can, how it is turned (HeldPad)
// p hands seen around a gamepad that has motion sensors: only where it is, and its
// heading for the sensors to hold on to
// n nothing is seen of it
struct PadStep {
float from;
char kind;
float right, up, ahead;
float yaw, pitch, roll;
};
std::vector<PadStep> pad_steps;
float shot_interval = 10.0f;
for (const auto& [name, value] : launch.extra_env) {
if (name == "HOST_SHOTS") {
// A picture every so many seconds instead of every ten.
shot_interval = std::max(1.0f, std::strtof(value.c_str(), nullptr));
}
if (name != "HOST_PAD") {
continue;
}
size_t at = 0;
while (at < value.size()) {
const size_t end = std::min(value.find(';', at), value.size());
PadStep step{};
if (std::sscanf(value.substr(at, end - at).c_str(), "%f:%c,%f,%f,%f,%f,%f,%f",
&step.from, &step.kind, &step.right, &step.up, &step.ahead, &step.yaw,
&step.pitch, &step.roll) == 8) {
pad_steps.push_back(step);
}
at = end + 1;
}
note("the controller follows %zu steps", pad_steps.size());
}
const PadStep* pad_step = nullptr;
bool pad_seen = false;
const auto start = Clock::now();
const auto seconds_since_start = [&] {
return std::chrono::duration<float>(Clock::now() - start).count();
};
uint32_t received = 0;
uint32_t saved = 0;
uint32_t last_frame_id = 0;
uint32_t interval_frames = 0;
float first_frame_at = -1.0f;
float next_save_at = 0.0f;
float interval_start = 0.0f;
double reported_cpu_seconds = 0.0;
float copy_ms = 0.0f;
float worst_copy_ms = 0.0f;
uint32_t copies = 0;
std::vector<uint8_t> pixels(static_cast<size_t>(width) * height * 4);
std::thread writer;
auto next_refresh = Clock::now();
for (uint32_t tick = 0; seconds_since_start() < static_cast<float>(seconds); ++tick) {
if (display_rate > 0.0f) {
core.SendRefresh(display_rate);
}
const float now = seconds_since_start();
const auto state = core.GetState();
if (state == CoreProcess::State::Failed || state == CoreProcess::State::Stopped) {
note("the core ended after %.1f s: %s", now, core.GetMessage().c_str());
break;
}
// A head that looks slowly left and right, the way a compositor reports it, or
// where it was told to look.
float yaw = 0.35f * std::sin(now * 0.8f);
float pitch = 0.0f;
Protocol::Pose pose;
if (looks.empty()) {
pose.angular_velocity[1] = 0.35f * 0.8f * std::cos(now * 0.8f);
} else {
yaw = 0.0f;
for (const Look& look : looks) {
if (now >= look.from) {
yaw = look.yaw / 57.29578f;
pitch = look.pitch / 57.29578f;
}
}
}
pose.orientation[0] = std::cos(yaw * 0.5f) * std::sin(pitch * 0.5f);
pose.orientation[1] = std::sin(yaw * 0.5f) * std::cos(pitch * 0.5f);
pose.orientation[2] = -std::sin(yaw * 0.5f) * std::sin(pitch * 0.5f);
pose.orientation[3] = std::cos(yaw * 0.5f) * std::cos(pitch * 0.5f);
core.SendPose(pose);
if (tick % 300 == 0) {
core.SendOptics(0.064f);
}
// Once, mid-sway: the player asks for the view to be reset (the emulator's log says
// where it then takes the seat to be).
if (!view_reset && looks.empty() && now > 33.0f) {
view_reset = true;
Protocol::PadPose reset;
reset.flags = Protocol::PadPose::RecenterSeat;
core.SendPadPose(reset);
note("view reset asked for with the head turned %.0f degrees", yaw * 57.29578f);
}
for (Turn& turn : turns) {
if (turn.done || now < turn.at) {
continue;
}
turn.done = true;
for (int step = 0; step < std::abs(turn.steps); ++step) {
Protocol::PadPose message;
message.flags = turn.steps > 0 ? Protocol::PadPose::TurnRight
: Protocol::PadPose::TurnLeft;
core.SendPadPose(message);
}
note("%.0f s: the view is asked to turn %d steps to the right", now, turn.steps);
}
// The controller, as a session would report it.
for (const PadStep& step : pad_steps) {
if (now >= step.from && (pad_step == nullptr || step.from > pad_step->from)) {
pad_step = &step;
note("%.0f s: the controller is '%c', %.2f right, %.2f up, %.2f ahead, turned "
"%.0f left, %.0f up, rolled %.0f",
now, step.kind, step.right, step.up, step.ahead, step.yaw, step.pitch,
step.roll);
}
}
if (pad_step != nullptr) {
const PadStep& step = *pad_step;
const float turn = step.yaw / 57.29578f;
const float tilt = step.pitch / 57.29578f;
const float roll = step.roll / 57.29578f;
const HeldPad::Vec right{std::cos(turn) * std::cos(roll), std::sin(roll),
-std::sin(turn) * std::cos(roll)};
const HeldPad::Vec level_front{-std::sin(turn), 0.0f, -std::cos(turn)};
const HeldPad::Vec level_up = HeldPad::Cross(right, level_front);
const HeldPad::Vec front{
level_front.x * std::cos(tilt) + level_up.x * std::sin(tilt),
level_front.y * std::cos(tilt) + level_up.y * std::sin(tilt),
level_front.z * std::cos(tilt) + level_up.z * std::sin(tilt)};
const HeldPad::Vec middle{step.right, step.up, -step.ahead};
Protocol::PadPose message;
const auto place = [&](const HeldPad::Vec& at) {
message.flags |= Protocol::PadPose::PositionValid;
message.position[0] = at.x;
message.position[1] = at.y;
message.position[2] = at.z;
};
const auto turned = [&](const HeldPad::Quat& q) {
message.flags |= Protocol::PadPose::OrientationValid;
message.orientation[0] = q.x;
message.orientation[1] = q.y;
message.orientation[2] = q.z;
message.orientation[3] = q.w;
};
if (step.kind == 'c') {
place(middle);
turned(HeldPad::FromAxes(right, HeldPad::Cross(right, front),
HeldPad::Scale(front, -1.0f)));
} else if (step.kind == 'h' || step.kind == 'p') {
// Two palms a gamepad's width apart, both hands pointing the way it does.
HeldPad::Hands hands;
hands.left_palm = HeldPad::Sub(middle, HeldPad::Scale(right, 0.1f));
hands.right_palm = HeldPad::Sub(middle, HeldPad::Scale(right, -0.1f));
hands.pointing_known = true;
hands.left_pointing = front;
hands.right_pointing = front;
if (const auto held = HeldPad::Locate(hands, 0.0f, 0.35f)) {
place(held->position);
if (held->turned_known) {
message.yaw = held->yaw;
message.flags |= Protocol::PadPose::YawValid;
if (step.kind == 'h') {
turned(held->orientation);
message.flags |= Protocol::PadPose::HeldInHands;
}
}
}
}
// While it is seen, every time; once more when it no longer is.
const bool seen = message.flags != 0;
if (seen || pad_seen) {
core.SendPadPose(message);
}
pad_seen = seen;
}
// Cross for a quarter of every five seconds gets a title past its menus.
PadState pad;
pad.buttons = std::fmod(now, 5.0f) < 0.25f && now > 20.0f ? 0x4000u : 0u;
core.SetPad(pad);
++shown_since;
if (const auto frame = core.TakeFrame()) {
if (something_shown) {
++shown_for[std::min<uint32_t>(shown_since, 4)];
}
something_shown = true;
shown_since = 0;
++received;
++interval_frames;
last_frame_id = frame->frame_id;
if (first_frame_at < 0.0f) {
first_frame_at = now;
note("first frame after %.1f s: buffer %u, eye %ux%u, fov tan %.3f/%.3f/%.3f/%.3f",
now, frame->buffer, frame->eye_width, frame->eye_height, frame->fov[0],
frame->fov[1], frame->fov[2], frame->fov[3]);
}
if (frames.IsValid(frame->buffer)) {
// What a headset session does with every frame, timed until the GPU is done
// with it: the copy has to get its turn on a GPU the emulator keeps busy, and
// the session's frame loop goes no faster than that.
const auto begun = Clock::now();
glBindFramebuffer(GL_FRAMEBUFFER, display_framebuffer);
blitter.Draw(frames.Texture(frame->buffer), width, height,
frame->swap_red_blue != 0, true, fov_mask, frame->fov);
const GLsync done = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
glClientWaitSync(done, GL_SYNC_FLUSH_COMMANDS_BIT, 2'000'000'000);
glDeleteSync(done);
const float took =
std::chrono::duration<float, std::milli>(Clock::now() - begun).count();
copy_ms += took;
worst_copy_ms = std::max(worst_copy_ms, took);
++copies;
}
if (frames.IsValid(frame->buffer) && now >= next_save_at && saved < 24) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
blitter.Draw(frames.Texture(frame->buffer), width, height,
frame->swap_red_blue != 0, false, fov_mask, frame->fov);
glReadPixels(0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height),
GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
char name[64];
std::snprintf(name, sizeof(name), "/frame_%03u_%05u.png", saved, frame->frame_id);
// Packing the picture takes many refreshes of the display this loop stands in
// for: that is done beside it.
if (writer.joinable()) {
writer.join();
}
writer = std::thread{[path = out_dir + name, copy = pixels, width, height] {
WritePng(path, copy, width, height);
}};
++saved;
next_save_at = now + shot_interval;
}
}
if (now - interval_start >= 10.0f) {
const CoreProcess::Usage usage = core.GetUsage();
note("%.0f s: %u frames, %.1f per second; emulator %.0f%% of a processor on %s, %d MB; "
"copying a frame took %.1f ms, %.1f at worst",
now, received, static_cast<float>(interval_frames) / (now - interval_start),
(usage.cpu_seconds - reported_cpu_seconds) * 100.0 / (now - interval_start),
usage.cpus.c_str(), usage.memory_mb,
copies != 0 ? copy_ms / static_cast<float>(copies) : 0.0f, worst_copy_ms);
if (const uint64_t interruptions = interrupter.Count(); interruptions != 0) {
note(" %.0f interruptions a second, the large pass took %.2f ms",
static_cast<float>(interruptions - reported_interruptions) /
(now - interval_start),
interrupter.TakePassTime());
reported_interruptions = interruptions;
}
if (display_rate > 0.0f) {
note(" frames shown for one refresh: %u, for two: %u, for three: %u, for "
"more: %u",
shown_for[1], shown_for[2], shown_for[3], shown_for[4]);
std::fill(std::begin(shown_for), std::end(shown_for), 0u);
}
reported_cpu_seconds = usage.cpu_seconds;
interval_start = now;
interval_frames = 0;
copy_ms = 0.0f;
worst_copy_ms = 0.0f;
copies = 0;
}
if (display_rate > 0.0f) {
next_refresh += std::chrono::nanoseconds{static_cast<int64_t>(1e9f / display_rate)};
std::this_thread::sleep_until(next_refresh);
} else {
std::this_thread::sleep_for(std::chrono::milliseconds{5});
}
}
if (writer.joinable()) {
writer.join();
}
note("%u frames received (last id %u), %u pictures in %s, GL error 0x%x", received,
last_frame_id, saved, out_dir.c_str(), glGetError());
interrupter.Stop();
core.Stop();
glDeleteFramebuffers(1, &framebuffer);
glDeleteTextures(1, &target);
glDeleteFramebuffers(1, &display_framebuffer);
glDeleteTextures(1, &display_target);
blitter.Destroy();
frames.Destroy(gl.display);
gl.Destroy();
return report;
}
} // namespace
extern "C" JNIEXPORT jstring JNICALL Java_com_astrobotquest_vrhost_SandboxShell_nativeSelfTest(
JNIEnv* env, jclass, jstring loader, jstring runtime_root, jstring storage_root, jstring game,
jstring log_file, jstring out_dir, jint seconds, jobjectArray extra_env, jstring microphone) {
CoreLaunch launch;
launch.loader = ToString(env, loader);
launch.runtime_root = ToString(env, runtime_root);
launch.storage_root = ToString(env, storage_root);
launch.game = ToString(env, game);
launch.log_file = ToString(env, log_file);
const jsize count = extra_env != nullptr ? env->GetArrayLength(extra_env) : 0;
for (jsize i = 0; i < count; ++i) {
const auto element = static_cast<jstring>(env->GetObjectArrayElement(extra_env, i));
const std::string entry = ToString(env, element);
env->DeleteLocalRef(element);
const size_t equals = entry.find('=');
if (equals != std::string::npos && equals > 0) {
launch.extra_env.emplace_back(entry.substr(0, equals), entry.substr(equals + 1));
}
}
const std::string report =
RunSelfTest(launch, ToString(env, out_dir), seconds, 1440, 1536,
ToString(env, microphone));
return env->NewStringUTF(report.c_str());
}
File diff suppressed because it is too large. Load diff
-112
View File
@@ -1,112 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <atomic>
#include <array>
#include <cstdint>
#include <mutex>
#include <vector>
#include <jni.h>
class CoreProcess;
class PadRouter;
/// A picture the Java side draws (text is far easier there) and the headset shows on a floating
/// panel whenever the game itself has nothing on screen.
struct StatusImage {
static constexpr int Width = 1024;
static constexpr int Height = 512;
std::mutex mutex;
std::vector<uint8_t> pixels; ///< RGBA, top row first
uint64_t version{};
};
struct XrHostOptions {
/// Display refresh rate to ask for, 0 keeps the system default.
float refresh_rate{0.0f};
/// Size of one eye in the buffers frames are delivered in.
uint32_t eye_width{1440};
uint32_t eye_height{1536};
/// Use the wearer's hands, as the headset sees them around the gamepad, to place the
/// controller in the game.
bool track_hands{true};
/// What the compositor does to the game's picture, which is coarser than the display, on
/// top of showing it: 0 and 1 nothing (with 1 the emulator sharpens the picture itself,
/// which is not this struct's business), 2 its Super Resolution filter on every refresh,
/// 3 the same but only while the system finds the GPU has the time for it, 4 its plainer
/// and cheaper sharpening filter.
int sharpen{1};
/// Have the compositor read the game's picture with a cubic filter instead of a linear
/// one where it enlarges it: sharper, for a little of the GPU's time on every refresh.
bool cubic{};
/// Keep the status panel in view (small, below the middle) while the game runs, for the
/// figures the app writes on it.
bool show_stats{};
/// Ask the system from moment to moment for the size it recommends for the picture. It
/// counts an app that asks among those that give way when the GPU is short, which is its
/// condition for the higher GPU clock levels. This app gives way where the GPU's time
/// goes: in the size the game draws its scene at, which the emulator sees to.
bool dynamic_resolution{true};
/// Also show the picture at the recommended size instead of at full size. That saves next
/// to nothing (the picture is copied once per frame of the game) and blurs what the game
/// drew; it is what the app did up to version 0.6.
bool follow_layer_size{};
/// Ask for the processor's "boost" level: its fastest clock for the first 45 seconds and
/// for up to a fifth of the time after that, as the system sees fit. That is when the
/// emulator translates most of the game's code.
bool cpu_boost{true};
/// For the probe that runs the host without anybody wearing the headset: ask the session
/// that never begins for everything that is otherwise asked when it does (refresh rate,
/// performance levels, thread hints), to have the answers in the log.
bool probe{};
/// How far beyond the next display refresh the head pose given to the game is predicted,
/// in milliseconds. A frame is on the display some tens of milliseconds after the game
/// read the pose it drew it for; aiming at that moment leaves the compositor less to
/// correct, which is what shows as black edges when the head turns.
float predict_ms{25.0f};
/// Let the headset's own two controllers play the game where the player holds them
/// instead of a gamepad (see PadRouter for what does what).
bool own_controllers{true};
/// Which of the two is the controller in the game then: 1 the right one, 0 the left.
int pad_hand{1};
/// Let them shake when the game shakes the gamepad.
bool rumble{true};
/// For a gamepad without motion sensors, which the hands holding it turn and tilt in the
/// game: how far its front is taken to point above (or, negative, below) the way the
/// hands point, in degrees.
float pad_tilt{0.0f};
};
/// What a request to reset the view asks for (bits of the counter RunXrHost is given).
namespace XrRecenter {
/// The way the controller points right now is straight ahead.
inline constexpr uint32_t Pad = 1;
/// Where the head is now is where the player sits, and the way it faces is straight ahead.
inline constexpr uint32_t Seat = 2;
} // namespace XrRecenter
/// What the host worked out about the session, for the status panel and for logging.
struct XrHostStatus {
std::atomic<bool> session_running{};
std::atomic<bool> hands_tracked{};
/// The one of the headset's own controllers that is the controller in the game is seen.
std::atomic<bool> controller_tracked{};
/// The panel is to be shown over the game: it has something to say to who is playing.
std::atomic<bool> show_panel{};
std::atomic<float> refresh_rate{};
/// Set when the startup choice is confirmed, before the game can deliver any frames.
std::atomic<bool> reduced_fov{};
/// Full projection tangents reported by this headset: out, in, up, down.
/// The startup menu snapshots them before the emulator is launched.
std::mutex optics_mutex;
std::array<float, 4> headset_fov{};
};
/// Owns the OpenXR session: feeds the head pose to the core and shows the stereo frames it
/// delivers, reads the headset's own controllers for `pads` and places the controller the
/// game is played with. Blocks until `quit` is set or the system ends the session.
void RunXrHost(JavaVM* vm, jobject activity, CoreProcess& core, PadRouter& pads,
StatusImage& status, const XrHostOptions& options, XrHostStatus& host_status,
std::atomic<bool>& quit, std::atomic<uint32_t>& recenter_requests);
-224
View File
@@ -1,224 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// What can be learnt about the headset's OpenXR runtime without a session, and so without
// anybody wearing the headset: that the app finds the runtime at all, which of the extensions
// the host relies on it has and, if it lets an instance be made for something that is not an
// activity, what it says about the system and what its performance counters are called.
// The last step runs the host itself for a few seconds: everything it sets up before a session
// begins (session, spaces, swapchains, hand trackers), which is as far as it gets with nobody
// wearing the headset.
// Runs from the SandboxShell instrumentation like the self test.
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
#include <jni.h>
#include "core_process.h"
#include "gl_frames.h"
#include "pad_router.h"
#include "xr_host.h"
#define XR_USE_PLATFORM_ANDROID
#define XR_USE_GRAPHICS_API_OPENGL_ES
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include "log.h"
namespace {
/// The extensions the host enables when they are there, and the two it cannot do without.
constexpr const char* Wanted[] = {
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME,
XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME,
XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME,
XR_FB_COMPOSITION_LAYER_SETTINGS_EXTENSION_NAME,
XR_META_AUTOMATIC_LAYER_FILTER_EXTENSION_NAME,
XR_EXT_HAND_TRACKING_EXTENSION_NAME,
XR_META_RECOMMENDED_LAYER_RESOLUTION_EXTENSION_NAME,
XR_META_PERFORMANCE_METRICS_EXTENSION_NAME,
};
template <typename Function>
Function GetFunction(XrInstance instance, const char* name) {
PFN_xrVoidFunction function = nullptr;
xrGetInstanceProcAddr(instance, name, &function);
return reinterpret_cast<Function>(function);
}
} // namespace
extern "C" JNIEXPORT jstring JNICALL Java_com_astrobotquest_vrhost_SandboxShell_nativeXrProbe(
JNIEnv* env, jclass, jobject context, jstring log_file, jint level) {
const char* log_path = env->GetStringUTFChars(log_file, nullptr);
SetLogFile(log_path);
env->ReleaseStringUTFChars(log_file, log_path);
std::string report;
const auto note = [&](const char* format, auto... values) {
char line[1024];
std::snprintf(line, sizeof(line), format, values...);
LOGI("xr probe: %s", line);
report += line;
report += '\n';
};
JavaVM* vm = nullptr;
env->GetJavaVM(&vm);
if (level >= 3) {
// The host as the app runs it, with no emulator behind it and nobody to show a session
// to: it sets itself up, waits for a session that does not come, and is told to stop.
CoreProcess core;
PadRouter pads;
StatusImage status;
XrHostOptions options;
options.probe = true;
XrHostStatus host_status;
std::atomic<bool> quit{false};
std::atomic<uint32_t> recenter_requests{0};
std::thread stopper{[&quit] {
std::this_thread::sleep_for(std::chrono::seconds{5});
quit = true;
}};
RunXrHost(vm, context, core, pads, status, options, host_status, quit,
recenter_requests);
stopper.join();
note("the host ran and stopped: session %s, refresh rate %.0f Hz",
host_status.session_running ? "running" : "not running",
host_status.refresh_rate.load());
return env->NewStringUTF(report.c_str());
}
const auto initialize_loader =
GetFunction<PFN_xrInitializeLoaderKHR>(XR_NULL_HANDLE, "xrInitializeLoaderKHR");
if (initialize_loader == nullptr) {
note("%s", "the loader has no xrInitializeLoaderKHR");
return env->NewStringUTF(report.c_str());
}
XrLoaderInitInfoAndroidKHR loader_info{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loader_info.applicationVM = vm;
loader_info.applicationContext = context;
const XrResult loader_result = initialize_loader(
reinterpret_cast<const XrLoaderInitInfoBaseHeaderKHR*>(&loader_info));
note("loader initialised: %d", static_cast<int>(loader_result));
if (XR_FAILED(loader_result)) {
return env->NewStringUTF(report.c_str());
}
uint32_t count = 0;
const XrResult enumerate_result =
xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr);
std::vector<XrExtensionProperties> available(count, {XR_TYPE_EXTENSION_PROPERTIES});
if (count != 0) {
xrEnumerateInstanceExtensionProperties(nullptr, count, &count, available.data());
}
note("the runtime lists %u extensions (%d)", count, static_cast<int>(enumerate_result));
std::vector<const char*> enabled;
for (const char* name : Wanted) {
const auto found = std::find_if(available.begin(), available.end(), [&](const auto& item) {
return std::strcmp(item.extensionName, name) == 0;
});
if (found != available.end()) {
note(" %s: version %u", name, found->extensionVersion);
enabled.push_back(name);
} else {
note(" %s: MISSING", name);
}
}
// All of them, for the log only.
std::string all;
for (const auto& item : available) {
all += std::string{all.empty() ? "" : " "} + item.extensionName;
}
LOGI("xr probe: extensions: %s", all.c_str());
if (level < 2) {
return env->NewStringUTF(report.c_str());
}
// An activity is what belongs here; there is none without somebody to show it to. Whether
// the runtime takes the app's context instead is part of what is being found out.
XrInstanceCreateInfoAndroidKHR android_info{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
android_info.applicationVM = vm;
android_info.applicationActivity = context;
XrInstanceCreateInfo instance_info{XR_TYPE_INSTANCE_CREATE_INFO};
instance_info.next = &android_info;
std::strcpy(instance_info.applicationInfo.applicationName, "Astro VR Host");
instance_info.applicationInfo.applicationVersion = 1;
instance_info.applicationInfo.apiVersion = XR_API_VERSION_1_0;
instance_info.enabledExtensionCount = static_cast<uint32_t>(enabled.size());
instance_info.enabledExtensionNames = enabled.data();
XrInstance instance = XR_NULL_HANDLE;
const XrResult create_result = xrCreateInstance(&instance_info, &instance);
note("instance created: %d", static_cast<int>(create_result));
if (XR_FAILED(create_result)) {
return env->NewStringUTF(report.c_str());
}
XrInstanceProperties properties{XR_TYPE_INSTANCE_PROPERTIES};
if (XR_SUCCEEDED(xrGetInstanceProperties(instance, &properties))) {
note("runtime: %s %u.%u.%u", properties.runtimeName,
static_cast<unsigned>(XR_VERSION_MAJOR(properties.runtimeVersion)),
static_cast<unsigned>(XR_VERSION_MINOR(properties.runtimeVersion)),
static_cast<unsigned>(XR_VERSION_PATCH(properties.runtimeVersion)));
}
XrSystemGetInfo system_info{XR_TYPE_SYSTEM_GET_INFO};
system_info.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
XrSystemId system = XR_NULL_SYSTEM_ID;
const XrResult system_result = xrGetSystem(instance, &system_info, &system);
note("system: %d", static_cast<int>(system_result));
if (XR_SUCCEEDED(system_result)) {
XrSystemHandTrackingPropertiesEXT hands{XR_TYPE_SYSTEM_HAND_TRACKING_PROPERTIES_EXT};
XrSystemProperties system_properties{XR_TYPE_SYSTEM_PROPERTIES};
system_properties.next = &hands;
if (XR_SUCCEEDED(xrGetSystemProperties(instance, system, &system_properties))) {
note(" %s: swapchain images up to %ux%u, %u layers, hand tracking %d",
system_properties.systemName,
system_properties.graphicsProperties.maxSwapchainImageWidth,
system_properties.graphicsProperties.maxSwapchainImageHeight,
system_properties.graphicsProperties.maxLayerCount,
static_cast<int>(hands.supportsHandTracking));
}
uint32_t view_count = 0;
XrViewConfigurationView views[2]{{XR_TYPE_VIEW_CONFIGURATION_VIEW},
{XR_TYPE_VIEW_CONFIGURATION_VIEW}};
if (XR_SUCCEEDED(xrEnumerateViewConfigurationViews(
instance, system, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 2, &view_count,
views)) &&
view_count == 2) {
note(" an eye: %ux%u recommended, %ux%u at most",
views[0].recommendedImageRectWidth, views[0].recommendedImageRectHeight,
views[0].maxImageRectWidth, views[0].maxImageRectHeight);
}
}
const auto enumerate_counters = GetFunction<PFN_xrEnumeratePerformanceMetricsCounterPathsMETA>(
instance, "xrEnumeratePerformanceMetricsCounterPathsMETA");
uint32_t counter_count = 0;
if (enumerate_counters != nullptr &&
XR_SUCCEEDED(enumerate_counters(instance, 0, &counter_count, nullptr))) {
std::vector<XrPath> paths(counter_count);
enumerate_counters(instance, counter_count, &counter_count, paths.data());
std::string names;
for (uint32_t i = 0; i < counter_count && i < paths.size(); ++i) {
char name[XR_MAX_PATH_LENGTH]{};
uint32_t length = 0;
if (XR_SUCCEEDED(xrPathToString(instance, paths[i], sizeof(name), &length, name))) {
names += std::string{names.empty() ? "" : " "} + name;
}
}
note("%u performance counters: %s", counter_count, names.c_str());
}
xrDestroyInstance(instance);
note("%s", "instance destroyed");
return env->NewStringUTF(report.c_str());
}
File diff suppressed because it is too large. Load diff
@@ -1,82 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package com.astrobotquest.vrhost;
import android.content.Context;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* Unpacks the Linux runtime the emulator runs in (its libraries, the GPU driver, the emulator
* core itself) from the app's assets into the app's files folder. The assets carry a stamp of
* what they hold; a folder with the same stamp is left alone.
*/
final class RuntimeInstaller {
private RuntimeInstaller() {}
/** Returns true when the runtime had to be unpacked, false when it was there already. */
static boolean install(Context context, File runtime) throws IOException {
String stamp;
try (InputStream in = context.getAssets().open("runtime.stamp")) {
stamp = new String(readAll(in), StandardCharsets.UTF_8).trim();
}
File stampFile = new File(runtime, ".stamp");
if (stampFile.isFile()
&& new String(Files.readAllBytes(stampFile.toPath()), StandardCharsets.UTF_8)
.trim().equals(stamp)) {
return false;
}
deleteRecursively(runtime);
runtime.mkdirs();
String root = runtime.getCanonicalPath() + File.separator;
byte[] buffer = new byte[1 << 16];
try (ZipInputStream zip = new ZipInputStream(context.getAssets().open("runtime.zip"))) {
for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
File target = new File(runtime, entry.getName());
if (!target.getCanonicalPath().startsWith(root)) {
throw new IOException("bad runtime entry " + entry.getName());
}
if (entry.isDirectory()) {
target.mkdirs();
continue;
}
target.getParentFile().mkdirs();
try (FileOutputStream out = new FileOutputStream(target)) {
for (int count = zip.read(buffer); count > 0; count = zip.read(buffer)) {
out.write(buffer, 0, count);
}
}
target.setExecutable(true, true);
}
}
Files.write(stampFile.toPath(), stamp.getBytes(StandardCharsets.UTF_8));
return true;
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
for (int count = in.read(buffer); count > 0; count = in.read(buffer)) {
out.write(buffer, 0, count);
}
return out.toByteArray();
}
static void deleteRecursively(File file) {
File[] children = file.listFiles();
if (children != null) {
for (File child : children) {
deleteRecursively(child);
}
}
file.delete();
}
}
@@ -1,313 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package com.astrobotquest.vrhost;
import android.app.Activity;
import android.app.Application;
import android.app.Instrumentation;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Log;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
/**
* Development aid: runs things inside the app's sandbox without any activity, which means
* without anybody wearing (and unlocking) the headset.
*
* "adb shell" and "run-as" are not what the emulator core meets when the app starts it: they run
* under different SELinux domains (on Quest neither may allocate GPU memory) and without the
* system-call filter apps get. This runs as a real app process instead.
*
* A shell command, in the app's files folder; the tail of its output comes back as the result:
*
* adb shell am instrument -w -e cmd "<command>" com.astrobotquest.vrhost/.SandboxShell
*
* The host's own code path, minus the headset session: the core is started the way the app
* starts it, gets a controller and a moving head over the app's sockets, renders into the app's
* shared frame buffers, and what arrives there is saved as pictures in files/selftest:
*
* adb shell am instrument -w -e selftest <seconds> [-e game <eboot.bin>] [-e env "A=1 B=2"] \
* [-e mic test|real|off] [-e install 1] com.astrobotquest.vrhost/.SandboxShell
*
* The game's microphone hears a test signal there (a second of noise every five), unless "mic"
* says to open the headset's own or none.
*
* The app's own start, without showing anything: the activity is created and its onCreate runs
* as it does when the app is launched (settings, OpenXR, runtime, emulator, status panel), for
* the given seconds; then it is destroyed. What comes back is the host.log it wrote:
*
* adb shell am instrument -w -e drystart <seconds> [-e settings <file like vrhost.txt>] \
* com.astrobotquest.vrhost/.SandboxShell
*
* What the headset's OpenXR runtime says of itself without a session ("1": that the app finds
* it and which of the extensions the host uses it has; "2": also what an instance tells, should
* the runtime let one be made without an activity; "3": the host itself, for the five seconds
* it takes to set up everything that comes before a session). The log is files/xrprobe.log:
*
* adb shell am instrument -w -e xrprobe 1 com.astrobotquest.vrhost/.SandboxShell
*/
public class SandboxShell extends Instrumentation {
private static final String TAG = "AstroVR";
private static final int MAX_OUTPUT = 60 * 1024;
private Bundle arguments;
private static native String nativeSelfTest(String loader, String runtimeRoot,
String storageRoot, String game, String logFile, String outDir, int seconds,
String[] extraEnv, String microphone);
private static native String nativeXrProbe(Object context, String logFile, int level);
@Override
public void onCreate(Bundle arguments) {
super.onCreate(arguments);
this.arguments = arguments;
start();
}
@Override
public void onStart() {
Bundle result = new Bundle();
try {
String selfTest = arguments.getString("selftest");
String xrProbe = arguments.getString("xrprobe");
String dryStart = arguments.getString("drystart");
String report;
if (dryStart != null) {
report = runDryStart(Integer.parseInt(dryStart));
} else if (xrProbe != null) {
System.loadLibrary("astrovr");
report = nativeXrProbe(getTargetContext(),
new File(getTargetContext().getFilesDir(), "xrprobe.log")
.getAbsolutePath(),
Integer.parseInt(xrProbe));
} else {
report = selfTest != null ? runSelfTest(Integer.parseInt(selfTest)) : runCommand();
}
result.putString(REPORT_KEY_STREAMRESULT, report);
} catch (Throwable e) {
Log.e(TAG, "sandbox run failed", e);
result.putString(REPORT_KEY_STREAMRESULT, "failed: " + e + "\n");
}
finish(Activity.RESULT_OK, result);
}
/**
* Creates the app's activity the way the system does, short of giving it a window on a
* display, and lets it start everything it starts.
*/
private String runDryStart(int seconds) throws Exception {
// Where the activity leaves the pictures of its status panel, emptied of the last run's.
File pictures = new File(getTargetContext().getFilesDir(), "drystart");
RuntimeInstaller.deleteRecursively(pictures);
pictures.mkdirs();
// (An earlier version of this test put them next to the logs.)
for (int number = 0; number < 8; ++number) {
new File(getTargetContext().getExternalFilesDir(null), "status-" + number + ".png")
.delete();
}
final Intent intent = new Intent(getTargetContext(), MainActivity.class);
intent.putExtra("dry_run", true);
if (arguments.getString("settings") != null) {
intent.putExtra("settings", arguments.getString("settings"));
}
final ActivityInfo info = getTargetContext().getPackageManager().getActivityInfo(
new ComponentName(getTargetContext(), MainActivity.class), 0);
final Activity[] activity = new Activity[1];
final Throwable[] failure = new Throwable[1];
runOnMainSync(() -> {
try {
activity[0] = newActivity(MainActivity.class, getTargetContext(), null,
(Application) getTargetContext().getApplicationContext(), intent, info,
"Astro VR Host", null, null, null);
callActivityOnCreate(activity[0], null);
} catch (Throwable e) {
failure[0] = e;
}
});
StringBuilder report = new StringBuilder();
if (failure[0] != null) {
Log.e(TAG, "dry start failed", failure[0]);
report.append("the activity failed to start: ").append(Log.getStackTraceString(failure[0]))
.append('\n');
} else {
// A controller that is not there: the events a gamepad would cause are handed to
// the activity as the system hands them over. Cross for a quarter of every five
// seconds gets the game past its first screens, the stick is moved in between.
long end = System.currentTimeMillis() + seconds * 1000L;
for (int step = 0; System.currentTimeMillis() < end; ++step) {
Thread.sleep(250);
final int phase = step % 20;
// Once, OPTIONS held for a second and a half and then the PS button: both ask
// for the view to be reset.
final int reset = step == 104 ? KeyEvent.KEYCODE_BUTTON_START
: step == 110 ? -KeyEvent.KEYCODE_BUTTON_START
: step == 114 ? KeyEvent.KEYCODE_BUTTON_MODE
: step == 115 ? -KeyEvent.KEYCODE_BUTTON_MODE : 0;
// Once, what a gamepad without a touchpad does about the touchpad: the right
// stick pulled back for most of a second and let go (the finger it moves),
// and R1 (which swipes forward). With env=SHADPS4_PAD_TOUCH_TRACE=1 among the
// settings, core.log says what the game was given of both.
final int gesture = step == 122 ? 1 : step == 125 ? 2
: step == 128 ? KeyEvent.KEYCODE_BUTTON_R1
: step == 129 ? -KeyEvent.KEYCODE_BUTTON_R1 : 0;
if (reset == 0 && gesture == 0
&& (step < 80 || (phase != 0 && phase != 1 && phase != 10))) {
continue;
}
runOnMainSync(() -> {
try {
if (reset != 0) {
activity[0].dispatchKeyEvent(new KeyEvent(
reset > 0 ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP,
Math.abs(reset)));
} else if (gesture == 1 || gesture == 2) {
activity[0].dispatchGenericMotionEvent(
stickEvent(0.0f, 0.0f, 0.0f, gesture == 1 ? 1.0f : 0.0f));
} else if (gesture != 0) {
activity[0].dispatchKeyEvent(new KeyEvent(
gesture > 0 ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP,
Math.abs(gesture)));
} else if (phase == 10) {
activity[0].dispatchGenericMotionEvent(
stickEvent(0.5f, -0.25f, 0.0f, 0.0f));
} else {
activity[0].dispatchKeyEvent(new KeyEvent(
phase == 0 ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_BUTTON_A));
}
} catch (Throwable e) {
failure[0] = e;
}
});
if (failure[0] != null) {
report.append("input failed: ").append(Log.getStackTraceString(failure[0]))
.append('\n');
failure[0] = null;
break;
}
}
}
if (activity[0] != null) {
runOnMainSync(() -> {
try {
callActivityOnDestroy(activity[0]);
} catch (Throwable e) {
failure[0] = e;
}
});
if (failure[0] != null) {
report.append("stopping failed: ").append(Log.getStackTraceString(failure[0]))
.append('\n');
}
}
File log = new File(getTargetContext().getExternalFilesDir(null), "host.log");
if (log.isFile()) {
report.append(new String(Files.readAllBytes(log.toPath()), StandardCharsets.UTF_8));
} else {
report.append("no host.log was written\n");
}
return report.toString();
}
/** What a gamepad reports when its sticks are moved: the left one, the right one. */
private static MotionEvent stickEvent(float x, float y, float rightX, float rightY) {
MotionEvent.PointerProperties[] properties = {new MotionEvent.PointerProperties()};
properties[0].id = 0;
MotionEvent.PointerCoords[] coords = {new MotionEvent.PointerCoords()};
coords[0].setAxisValue(MotionEvent.AXIS_X, x);
coords[0].setAxisValue(MotionEvent.AXIS_Y, y);
coords[0].setAxisValue(MotionEvent.AXIS_Z, rightX);
coords[0].setAxisValue(MotionEvent.AXIS_RZ, rightY);
long now = SystemClock.uptimeMillis();
return MotionEvent.obtain(now, now, MotionEvent.ACTION_MOVE, 1, properties, coords, 0, 0,
1.0f, 1.0f, 0, 0, InputDevice.SOURCE_JOYSTICK, 0);
}
private String runCommand() throws Exception {
String command = arguments.getString("cmd", "id");
File directory = getTargetContext().getFilesDir();
directory.mkdirs();
ProcessBuilder builder = new ProcessBuilder("/system/bin/sh", "-c", command);
builder.directory(directory);
builder.redirectErrorStream(true);
builder.environment().put("ASTRO_LIB_DIR",
getTargetContext().getApplicationInfo().nativeLibraryDir);
builder.environment().put("ASTRO_EXTERNAL_DIR",
String.valueOf(getTargetContext().getExternalFilesDir(null)));
Process process = builder.start();
// Keep the end of the output, that is where failures are.
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
try (InputStream in = process.getInputStream()) {
for (int count = in.read(buffer); count > 0; count = in.read(buffer)) {
output.write(buffer, 0, count);
if (output.size() > 2 * MAX_OUTPUT) {
byte[] all = output.toByteArray();
output.reset();
output.write(all, all.length - MAX_OUTPUT, MAX_OUTPUT);
}
}
}
int exit = process.waitFor();
byte[] all = output.toByteArray();
int from = Math.max(0, all.length - MAX_OUTPUT);
return new String(all, from, all.length - from, StandardCharsets.UTF_8)
+ "\nexit=" + exit + "\n";
}
private String runSelfTest(int seconds) {
System.loadLibrary("astrovr");
File files = getTargetContext().getFilesDir();
File out = new File(files, "selftest");
RuntimeInstaller.deleteRecursively(out);
out.mkdirs();
File storage = new File(files, "selftest-core");
storage.mkdirs();
// "install": the runtime comes out of the app's own assets first, as on the app's first
// start, instead of being whatever the test scripts last put there.
String installed = "";
if (arguments.getString("install") != null) {
try {
File runtime = new File(files, "runtime");
new File(runtime, ".stamp").delete();
long begin = System.currentTimeMillis();
RuntimeInstaller.install(getTargetContext(), runtime);
installed = "runtime unpacked from the app in "
+ (System.currentTimeMillis() - begin) + " ms\n";
} catch (java.io.IOException e) {
return "the runtime could not be unpacked: " + e + "\n";
}
}
List<String> env = new ArrayList<>();
env.add("XDG_DATA_HOME=" + new File(out, "data"));
env.add("SHADPS4_MAX_MSAA=1");
for (String entry : arguments.getString("env", "").split(" ")) {
if (!entry.isEmpty()) {
env.add(entry);
}
}
String game = arguments.getString("game", "/data/local/tmp/astro/games/CUSA12392/eboot.bin");
return installed + nativeSelfTest(
getTargetContext().getApplicationInfo().nativeLibraryDir + "/libastro_ld.so",
new File(files, "runtime").getAbsolutePath(), storage.getAbsolutePath(), game,
new File(out, "core.log").getAbsolutePath(), out.getAbsolutePath(), seconds,
env.toArray(new String[0]), arguments.getString("mic", "test"));
}
}
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Astro VR Host</string>
</resources>
-625
View File
@@ -1,625 +0,0 @@
// SPDX-License-Identifier: MIT
//
// Lets Mesa's Turnip driver talk to the Adreno kernel driver (KGSL) on Meta Quest.
//
// Horizon OS filters KGSL ioctls by number for applications and only lets through the ones its
// own graphics driver uses, the "GPUOBJ" generation of the interface. Turnip still issues a few
// calls of the generation before, which are answered with EACCES there:
//
// IOCTL_KGSL_GPUMEM_ALLOC_ID allocate a buffer
// IOCTL_KGSL_GPUMEM_FREE_ID free it
// IOCTL_KGSL_GPUMEM_GET_INFO describe it
// IOCTL_KGSL_GPUMEM_SYNC_CACHE(_BULK) flush CPU caches for it
// IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID ask how far a context has got
//
// This library is preloaded into the emulator process (the glibc loader's --preload) and stands
// in front of libc's ioctl(): those calls are carried out with what is permitted, everything else
// goes to the kernel untouched.
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
#define KGSL_IOC_TYPE 0x09
// --- the calls that are refused ----------------------------------------------------------------
struct kgsl_gpumem_alloc_id {
unsigned int id;
unsigned int flags;
size_t size;
size_t mmapsize;
unsigned long gpuaddr;
unsigned long pad[2];
};
struct kgsl_gpumem_free_id {
unsigned int id;
unsigned int pad;
};
struct kgsl_gpumem_get_info {
unsigned long gpuaddr;
unsigned int id;
unsigned int flags;
size_t size;
size_t mmapsize;
unsigned long useraddr;
unsigned long pad[4];
};
struct kgsl_gpumem_sync_cache {
unsigned long gpuaddr;
unsigned int id;
unsigned int op;
size_t offset;
size_t length;
};
struct kgsl_gpumem_sync_cache_bulk {
unsigned int* id_list;
unsigned int count;
unsigned int op;
unsigned int pad[2];
};
struct kgsl_cmdstream_readtimestamp_ctxtid {
unsigned int context_id;
unsigned int type;
unsigned int timestamp;
};
enum {
NR_WAITTIMESTAMP_CTXTID = 0x07,
NR_DRAWCTXT_CREATE = 0x13,
NR_READTIMESTAMP_CTXTID = 0x16,
NR_TIMESTAMP_EVENT = 0x33,
NR_GPUMEM_ALLOC_ID = 0x34,
NR_GPUMEM_FREE_ID = 0x35,
NR_GPUMEM_GET_INFO = 0x36,
NR_GPUMEM_SYNC_CACHE = 0x37,
NR_GPUMEM_SYNC_CACHE_BULK = 0x3c,
NR_GPU_COMMAND = 0x4a,
NR_GPU_AUX_COMMAND = 0x57,
};
// --- the calls that are permitted --------------------------------------------------------------
struct kgsl_device_waittimestamp_ctxtid {
unsigned int context_id;
unsigned int timestamp;
unsigned int timeout;
};
#define IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID \
_IOW(KGSL_IOC_TYPE, 0x7, struct kgsl_device_waittimestamp_ctxtid)
struct kgsl_gpuobj_alloc {
uint64_t size;
uint64_t flags;
uint64_t va_len;
uint64_t mmapsize;
unsigned int id;
unsigned int metadata_len;
uint64_t metadata;
};
#define IOCTL_KGSL_GPUOBJ_ALLOC _IOWR(KGSL_IOC_TYPE, 0x45, struct kgsl_gpuobj_alloc)
struct kgsl_gpuobj_free {
uint64_t flags;
uint64_t priv;
unsigned int id;
unsigned int type;
unsigned int len;
};
#define IOCTL_KGSL_GPUOBJ_FREE _IOW(KGSL_IOC_TYPE, 0x46, struct kgsl_gpuobj_free)
struct kgsl_gpuobj_info {
uint64_t gpuaddr;
uint64_t flags;
uint64_t size;
uint64_t va_len;
uint64_t va_addr;
unsigned int id;
};
#define IOCTL_KGSL_GPUOBJ_INFO _IOWR(KGSL_IOC_TYPE, 0x47, struct kgsl_gpuobj_info)
struct kgsl_gpuobj_sync_obj {
uint64_t offset;
uint64_t length;
unsigned int id;
unsigned int op;
};
struct kgsl_gpuobj_sync {
uint64_t objs;
unsigned int obj_len;
unsigned int count;
};
#define IOCTL_KGSL_GPUOBJ_SYNC _IOW(KGSL_IOC_TYPE, 0x49, struct kgsl_gpuobj_sync)
/// The parts of the two submit calls that are of interest here: they share this tail.
struct kgsl_gpu_command {
uint64_t flags;
uint64_t cmdlist;
unsigned int cmdsize;
unsigned int numcmds;
uint64_t objlist;
unsigned int objsize;
unsigned int numobjs;
uint64_t synclist;
unsigned int syncsize;
unsigned int numsyncs;
unsigned int context_id;
unsigned int timestamp;
};
struct kgsl_gpu_aux_command {
uint64_t flags;
uint64_t cmdlist;
unsigned int cmdsize;
unsigned int numcmds;
uint64_t synclist;
unsigned int syncsize;
unsigned int numsyncs;
unsigned int context_id;
unsigned int timestamp;
};
static int kernel_ioctl(int fd, unsigned long request, void* arg) {
return (int)syscall(SYS_ioctl, fd, request, arg);
}
static int tracing(void);
static void trace(const char* format, ...);
// --- buffers -----------------------------------------------------------------------------------
static size_t g_allocated;
static int gpumem_alloc_id(int fd, struct kgsl_gpumem_alloc_id* legacy) {
struct kgsl_gpuobj_alloc alloc;
memset(&alloc, 0, sizeof(alloc));
alloc.size = legacy->size;
alloc.flags = legacy->flags;
if (kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_ALLOC, &alloc) != 0) {
return -1;
}
// The old call also reported where the buffer sits in the GPU's address space.
struct kgsl_gpuobj_info info;
memset(&info, 0, sizeof(info));
info.id = alloc.id;
if (kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_INFO, &info) != 0) {
const int error = errno;
struct kgsl_gpuobj_free release;
memset(&release, 0, sizeof(release));
release.id = alloc.id;
kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_FREE, &release);
errno = error;
return -1;
}
legacy->id = alloc.id;
legacy->flags = (unsigned int)info.flags;
legacy->size = info.size;
legacy->mmapsize = alloc.mmapsize;
legacy->gpuaddr = info.gpuaddr;
// GPU memory is ordinary memory here, backed by pages from the moment it is allocated, and
// a headset has little of it to spare: with tracing on, the large allocations are listed.
const size_t total = __atomic_add_fetch(&g_allocated, info.size, __ATOMIC_RELAXED);
if (tracing() && info.size >= (8u << 20)) {
trace("allocated %zu MB of GPU memory, %zu MB in all", (size_t)info.size >> 20,
total >> 20);
}
return 0;
}
static int gpumem_free_id(int fd, const struct kgsl_gpumem_free_id* legacy) {
struct kgsl_gpuobj_free release;
memset(&release, 0, sizeof(release));
release.id = legacy->id;
return kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_FREE, &release);
}
static int gpumem_get_info(int fd, struct kgsl_gpumem_get_info* legacy) {
if (legacy->id == 0) {
// Looking a buffer up by address has no successor.
errno = EINVAL;
return -1;
}
struct kgsl_gpuobj_info info;
memset(&info, 0, sizeof(info));
info.id = legacy->id;
if (kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_INFO, &info) != 0) {
return -1;
}
legacy->gpuaddr = info.gpuaddr;
legacy->flags = (unsigned int)info.flags;
legacy->size = info.size;
legacy->mmapsize = info.va_len != 0 ? info.va_len : info.size;
legacy->useraddr = info.va_addr;
return 0;
}
static int gpumem_sync_cache(int fd, const struct kgsl_gpumem_sync_cache* legacy) {
struct kgsl_gpuobj_sync_obj object;
memset(&object, 0, sizeof(object));
object.offset = legacy->offset;
object.length = legacy->length;
object.id = legacy->id;
object.op = legacy->op;
struct kgsl_gpuobj_sync sync;
memset(&sync, 0, sizeof(sync));
sync.objs = (uintptr_t)&object;
sync.obj_len = sizeof(object);
sync.count = 1;
return kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_SYNC, &sync);
}
static int gpumem_sync_cache_bulk(int fd, const struct kgsl_gpumem_sync_cache_bulk* legacy) {
if (legacy->count == 0) {
return 0;
}
struct kgsl_gpuobj_sync_obj* objects = calloc(legacy->count, sizeof(*objects));
if (objects == NULL) {
errno = ENOMEM;
return -1;
}
for (unsigned int i = 0; i < legacy->count; ++i) {
// Without the "range" bit in op the whole buffer is meant.
objects[i].id = legacy->id_list[i];
objects[i].op = legacy->op;
}
struct kgsl_gpuobj_sync sync;
memset(&sync, 0, sizeof(sync));
sync.objs = (uintptr_t)objects;
sync.obj_len = sizeof(*objects);
sync.count = legacy->count;
const int result = kernel_ioctl(fd, IOCTL_KGSL_GPUOBJ_SYNC, &sync);
const int error = errno;
free(objects);
errno = error;
return result;
}
// --- timestamps --------------------------------------------------------------------------------
//
// The call that reads how far a context has got is refused. The kernel also publishes those
// counters in a page any process may map read-only though, the "memstore", which is how
// Qualcomm's own driver follows them, and reading that costs no system call at all.
//
// Should the page not be available, the timestamps handed out on submission are noted and "the
// last retired one" is found by asking, one by one, whether they have been reached. That call
// can only wait: a timeout of zero means forever, so the shortest real one is used, and Turnip,
// which asks on every submission, then spends its time waiting for the GPU.
struct kgsl_shadowprop {
unsigned long gpuaddr;
size_t size;
unsigned int flags;
};
struct kgsl_device_getproperty {
unsigned int type;
void* value;
size_t sizebytes;
};
#define KGSL_PROP_DEVICE_SHADOW 0x2
#define IOCTL_KGSL_DEVICE_GETPROPERTY _IOWR(KGSL_IOC_TYPE, 0x2, struct kgsl_device_getproperty)
/// One entry per context id.
struct kgsl_devmemstore {
volatile unsigned int soptimestamp; // last command batch started
unsigned int sbz;
volatile unsigned int eoptimestamp; // last command batch finished
unsigned int sbz2;
volatile unsigned int preempted;
unsigned int sbz3;
volatile unsigned int ref_wait_ts;
unsigned int sbz4;
unsigned int current_context;
unsigned int sbz5;
};
#define KGSL_TIMESTAMP_CONSUMED 1
#define KGSL_TIMESTAMP_RETIRED 2
#define MAX_CONTEXTS 16
#define MAX_PENDING 256
struct context_state {
unsigned int id;
int used;
unsigned int newest; // last timestamp handed out
unsigned int retired; // last timestamp known to be done
unsigned int pending[MAX_PENDING]; // submitted and not yet seen done, oldest first
unsigned int count;
};
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static struct context_state g_contexts[MAX_CONTEXTS];
static const struct kgsl_devmemstore* g_memstore;
static size_t g_memstore_entries;
static int g_memstore_tried;
/// Called with the lock held.
static void map_memstore(int fd) {
g_memstore_tried = 1;
struct kgsl_shadowprop shadow;
memset(&shadow, 0, sizeof(shadow));
struct kgsl_device_getproperty property;
memset(&property, 0, sizeof(property));
property.type = KGSL_PROP_DEVICE_SHADOW;
property.value = &shadow;
property.sizebytes = sizeof(shadow);
if (kernel_ioctl(fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &property) != 0 || shadow.size == 0) {
fprintf(stderr, "kgsl_compat: no timestamp page (%s), GPU waits will be slow\n",
strerror(errno));
return;
}
// The kernel recognises the mapping by its offset and insists on read-only and the full size.
void* mapping = mmap(NULL, shadow.size, PROT_READ, MAP_SHARED, fd, (off_t)shadow.gpuaddr);
if (mapping == MAP_FAILED) {
fprintf(stderr,
"kgsl_compat: cannot map the timestamp page (%s), GPU waits will be slow\n",
strerror(errno));
return;
}
g_memstore = mapping;
g_memstore_entries = shadow.size / sizeof(struct kgsl_devmemstore);
fprintf(stderr, "kgsl_compat: reading timestamps from the kernel page, %zu contexts\n",
g_memstore_entries);
}
static struct context_state* find_context(unsigned int id) {
struct context_state* unused = NULL;
for (int i = 0; i < MAX_CONTEXTS; ++i) {
if (g_contexts[i].used && g_contexts[i].id == id) {
return &g_contexts[i];
}
if (!g_contexts[i].used && unused == NULL) {
unused = &g_contexts[i];
}
}
if (unused == NULL) {
// Contexts come and go rarely; recycle the first slot rather than fail.
unused = &g_contexts[0];
}
memset(unused, 0, sizeof(*unused));
unused->used = 1;
unused->id = id;
return unused;
}
static void note_submission(unsigned int context_id, unsigned int timestamp) {
pthread_mutex_lock(&g_lock);
struct context_state* context = find_context(context_id);
context->newest = timestamp;
if (g_memstore == NULL) {
if (context->count == MAX_PENDING) {
// Nobody asked for a long time. The oldest half is done for sure by now.
context->retired = context->pending[MAX_PENDING / 2 - 1];
memmove(context->pending, context->pending + MAX_PENDING / 2,
sizeof(context->pending[0]) * (MAX_PENDING / 2));
context->count = MAX_PENDING / 2;
}
context->pending[context->count++] = timestamp;
}
pthread_mutex_unlock(&g_lock);
}
static int is_retired(int fd, unsigned int context_id, unsigned int timestamp) {
struct kgsl_device_waittimestamp_ctxtid wait;
wait.context_id = context_id;
wait.timestamp = timestamp;
wait.timeout = 1;
return kernel_ioctl(fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait) == 0;
}
static int read_timestamp(int fd, struct kgsl_cmdstream_readtimestamp_ctxtid* request) {
const int saved_errno = errno;
pthread_mutex_lock(&g_lock);
if (!g_memstore_tried) {
map_memstore(fd);
}
struct context_state* context = find_context(request->context_id);
if (request->type != KGSL_TIMESTAMP_CONSUMED && request->type != KGSL_TIMESTAMP_RETIRED) {
// Queued: the newest one handed out.
request->timestamp = context->newest;
} else if (g_memstore != NULL && request->context_id < g_memstore_entries) {
const struct kgsl_devmemstore* entry = &g_memstore[request->context_id];
request->timestamp = request->type == KGSL_TIMESTAMP_RETIRED ? entry->eoptimestamp
: entry->soptimestamp;
} else {
// Submissions retire in order: drop every leading one that is done.
unsigned int done = 0;
while (done < context->count &&
is_retired(fd, request->context_id, context->pending[done])) {
context->retired = context->pending[done];
++done;
}
if (done > 0) {
memmove(context->pending, context->pending + done,
sizeof(context->pending[0]) * (context->count - done));
context->count -= done;
}
request->timestamp = context->retired;
}
pthread_mutex_unlock(&g_lock);
errno = saved_errno;
return 0;
}
// --- priority ----------------------------------------------------------------------------------
//
// The GPU works through the commands of several programs: here the emulator's, the host app's
// (which copies each finished picture to where the headset's compositor takes it from) and the
// compositor's. The kernel driver keeps one queue per priority level and lets a higher one
// interrupt a lower one. Turnip asks for no priority, which makes the emulator the equal of the
// host app: the app's small copy then waits behind everything the emulator has queued up, tens
// of milliseconds when the game is busy, and a picture that is ready is not shown.
//
// KGSL_COMPAT_PRIORITY=<n> gives the emulator's GPU contexts priority n instead: 0 is the
// highest, 8 what a context gets when it asks for nothing, 12 to 15 the lowest level. Should the
// kernel refuse, the context is created the way the driver asked for it.
struct kgsl_drawctxt_create {
unsigned int flags;
unsigned int drawctxt_id;
};
#define KGSL_CONTEXT_PRIORITY_MASK 0x0000f000u
#define KGSL_CONTEXT_PRIORITY_SHIFT 12
static int create_context(int fd, unsigned long request, struct kgsl_drawctxt_create* create) {
const unsigned int asked = create->flags;
const char* setting = getenv("KGSL_COMPAT_PRIORITY");
const int priority = setting != NULL && setting[0] != '\0' ? atoi(setting) : -1;
if (priority >= 0 && priority <= 15) {
create->flags = (asked & ~KGSL_CONTEXT_PRIORITY_MASK) |
((unsigned int)priority << KGSL_CONTEXT_PRIORITY_SHIFT);
}
int result = kernel_ioctl(fd, request, create);
if (result != 0 && create->flags != asked) {
const int error = errno;
create->flags = asked;
result = kernel_ioctl(fd, request, create);
fprintf(stderr, "kgsl_compat: priority %d refused (%s), context created as asked\n",
priority, strerror(error));
}
if (result == 0) {
// The kernel writes back what the context got.
fprintf(stderr, "kgsl_compat: GPU context %u, priority %u (flags 0x%x, asked 0x%x)\n",
create->drawctxt_id,
(create->flags & KGSL_CONTEXT_PRIORITY_MASK) >> KGSL_CONTEXT_PRIORITY_SHIFT,
create->flags, asked);
}
return result;
}
// --- tracing -----------------------------------------------------------------------------------
//
// KGSL_COMPAT_TRACE=1 in the environment logs every submission and every timestamp call to
// stderr, with the calling thread and a millisecond clock: enough to see why a fence does not
// signal or what a thread is waiting for.
struct kgsl_timestamp_event {
int type;
unsigned int timestamp;
unsigned int context_id;
void* priv;
size_t len;
};
static int tracing(void) {
static int enabled = -1;
if (enabled < 0) {
enabled = getenv("KGSL_COMPAT_TRACE") != NULL;
}
return enabled;
}
static void trace(const char* format, ...) {
const int saved_errno = errno;
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
char line[256];
va_list arguments;
va_start(arguments, format);
vsnprintf(line, sizeof(line), format, arguments);
va_end(arguments);
fprintf(stderr, "kgsl %ld.%03ld [%ld] %s\n", (long)(now.tv_sec % 1000),
now.tv_nsec / 1000000, (long)syscall(SYS_gettid), line);
errno = saved_errno;
}
// --- entry point -------------------------------------------------------------------------------
int ioctl(int fd, unsigned long request, ...) {
va_list arguments;
va_start(arguments, request);
void* arg = va_arg(arguments, void*);
va_end(arguments);
if (_IOC_TYPE(request) != KGSL_IOC_TYPE || arg == NULL) {
return kernel_ioctl(fd, request, arg);
}
switch (_IOC_NR(request)) {
case NR_DRAWCTXT_CREATE:
return create_context(fd, request, arg);
case NR_GPUMEM_ALLOC_ID:
return gpumem_alloc_id(fd, arg);
case NR_GPUMEM_FREE_ID:
return gpumem_free_id(fd, arg);
case NR_GPUMEM_GET_INFO:
return gpumem_get_info(fd, arg);
case NR_GPUMEM_SYNC_CACHE:
return gpumem_sync_cache(fd, arg);
case NR_GPUMEM_SYNC_CACHE_BULK:
return gpumem_sync_cache_bulk(fd, arg);
case NR_READTIMESTAMP_CTXTID: {
const int result = read_timestamp(fd, arg);
if (tracing()) {
const struct kgsl_cmdstream_readtimestamp_ctxtid* read = arg;
trace("read context %u type %u -> %u", read->context_id, read->type,
read->timestamp);
}
return result;
}
case NR_WAITTIMESTAMP_CTXTID: {
const struct kgsl_device_waittimestamp_ctxtid* wait = arg;
const unsigned int timestamp = wait->timestamp;
const unsigned int timeout = wait->timeout;
const int result = kernel_ioctl(fd, request, arg);
if (tracing()) {
trace("wait context %u for %u, timeout %u -> %d%s%s", wait->context_id, timestamp,
timeout, result, result != 0 ? " " : "", result != 0 ? strerror(errno) : "");
}
return result;
}
case NR_TIMESTAMP_EVENT: {
const int result = kernel_ioctl(fd, request, arg);
if (tracing()) {
const struct kgsl_timestamp_event* event = arg;
trace("fence for context %u timestamp %u -> %d", event->context_id,
event->timestamp, result);
}
return result;
}
case NR_GPU_COMMAND: {
const int result = kernel_ioctl(fd, request, arg);
const struct kgsl_gpu_command* command = arg;
if (result == 0) {
note_submission(command->context_id, command->timestamp);
}
if (tracing()) {
trace("submit context %u, %u commands, %u sync points -> %d timestamp %u%s%s",
command->context_id, command->numcmds, command->numsyncs, result,
command->timestamp, result != 0 ? " " : "", result != 0 ? strerror(errno) : "");
}
return result;
}
case NR_GPU_AUX_COMMAND: {
const int result = kernel_ioctl(fd, request, arg);
if (result == 0) {
const struct kgsl_gpu_aux_command* command = arg;
note_submission(command->context_id, command->timestamp);
}
return result;
}
default:
return kernel_ioctl(fd, request, arg);
}
}
@@ -137,6 +137,8 @@ constexpr s32 SlowestPace = 6;
/// The refreshes of the display frames are given at the moment, for the thread that makes
/// the headset's.
std::atomic<u32> frame_pace{0};
/// The scene's size for an eye, width in the high half; 0 where nothing is known.
std::atomic<u64> scene_eye_size{0};
struct Settings {
bool time_step{true};
@@ -186,6 +188,10 @@ const Settings& GetSettings() {
parsed.resolution = Settings::Resolution::Pinned;
parsed.pinned_level = level;
}
} else if (const char* value = std::getenv("SHADPS4_VR_FOVEATION");
value != nullptr && value[0] == '1') {
parsed.resolution = Settings::Resolution::Pinned;
parsed.pinned_level = 5;
}
if (const char* value = std::getenv("SHADPS4_VR_PACE"); value != nullptr) {
const s32 pace = std::atoi(value);
@@ -832,6 +838,10 @@ void OnFrameSubmitted() {
}
const s32 resolution = TendResolution(base, *build, wanted);
frame_pace.store(governor.Pace(), std::memory_order_relaxed);
if (resolution >= 0 && resolution <= LastHeadsetLevel) {
const auto& size = GetLarger().sizes[resolution];
scene_eye_size.store(u64{size[0]} << 32 | size[1], std::memory_order_relaxed);
}
if (settings.time_step) {
// Shorter steps than the title's own only where it is made to draw faster than it
@@ -873,6 +883,11 @@ u32 FramePace() {
return frame_pace.load(std::memory_order_relaxed);
}
std::array<u32, 2> SceneEyeSize() {
const u64 size = scene_eye_size.load(std::memory_order_relaxed);
return {static_cast<u32>(size >> 32), static_cast<u32>(size)};
}
void Prepare() {
const auto& serial = Common::ElfInfo::Instance().GameSerial();
if (serial != "CUSA12392" && serial != "CUSA12431") {
@@ -3,6 +3,8 @@
#pragma once
#include <array>
#include "common/types.h"
namespace Core::Vr {
@@ -43,6 +45,9 @@ void OnFrameSubmitted();
/// the display again, at a frame for every three refreshes.
u32 FramePace();
/// The size the title draws its scene at for one eye just now, {0, 0} where nothing is known.
std::array<u32, 2> SceneEyeSize();
/// Called with the head position just handed to the title: what the title makes of it goes to
/// the log every now and then, and whenever the title takes stock of the player's seat anew.
void NoteView(const Vr::Vec3& tracker_head);
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <vector>
#include "core/vr/vr_runtime.h"
namespace Core::Vr {
/// A stereo map has the eyes side by side; otherwise the whole width is one eye.
// ponytail: one eye's fov serves both; the right eye's in/out asymmetry (<1 degree on Frame)
// shifts its map slightly. Mirror the fov for the right eye if a headset is more lopsided.
inline std::vector<u8> BuildFoveationMap(u32 width, u32 height, u32 texel_width,
u32 texel_height, const Fov& fov,
const GazeTarget& gaze, bool stereo = true) {
if (width == 0 || height == 0 || texel_width == 0 || texel_height == 0) {
return {};
}
const u32 map_width = (width + texel_width - 1) / texel_width;
const u32 map_height = (height + texel_height - 1) / texel_height;
std::vector<u8> rates(map_width * map_height, 0);
if (!gaze.valid || width < 2) {
return rates;
}
constexpr u8 Rate1x1 = 0;
constexpr u8 Rate2x2 = 5;
constexpr u8 Rate4x4 = 10;
constexpr float RadiansToDegrees = 57.2957795131f;
for (u32 y = 0; y < map_height; ++y) {
const float v = std::min((y * texel_height + texel_height * 0.5f) / height, 1.0f);
const float tan_y = (1.0f - v) * fov.tan_top - v * fov.tan_bottom;
for (u32 x = 0; x < map_width; ++x) {
const float u = std::min((x * texel_width + texel_width * 0.5f) / width, 1.0f);
const u32 eye = stereo && u >= 0.5f ? 1 : 0;
const float local_u = !stereo ? u : eye == 0 ? u * 2.0f : u * 2.0f - 1.0f;
const float tan_x = -fov.tan_out + local_u * (fov.tan_out + fov.tan_in);
const float gaze_x = eye == 0 ? gaze.left_x * 2.0f : gaze.right_x * 2.0f - 1.0f;
const float gaze_tan_x = -fov.tan_out + gaze_x * (fov.tan_out + fov.tan_in);
const float gaze_tan_y = (1.0f - gaze.y) * fov.tan_top - gaze.y * fov.tan_bottom;
const float dx = std::atan(tan_x) - std::atan(gaze_tan_x);
const float dy = std::atan(tan_y) - std::atan(gaze_tan_y);
const float angle = std::hypot(dx, dy) * RadiansToDegrees;
rates[y * map_width + x] =
angle <= 10.0f ? Rate1x1 : angle <= 35.0f ? Rate2x2 : Rate4x4;
}
}
return rates;
}
} // namespace Core::Vr
+106 -1
View File
@@ -325,6 +325,7 @@ struct OpenXrHost::Impl {
bool available{};
bool found_at_connect{};
bool hand_extension{};
bool gaze_extension{};
bool has_hand_tracking{};
bool has_refresh_rate{};
bool has_audio_guid{};
@@ -442,6 +443,10 @@ struct OpenXrHost::Impl {
XrAction act_square{XR_NULL_HANDLE};
XrAction act_circle{XR_NULL_HANDLE};
XrAction act_triangle{XR_NULL_HANDLE};
XrAction act_up{XR_NULL_HANDLE};
XrAction act_down{XR_NULL_HANDLE};
XrAction act_left{XR_NULL_HANDLE};
XrAction act_right{XR_NULL_HANDLE};
XrAction act_l1{XR_NULL_HANDLE};
XrAction act_r1{XR_NULL_HANDLE};
XrAction act_l2{XR_NULL_HANDLE};
@@ -450,10 +455,13 @@ struct OpenXrHost::Impl {
XrAction act_l3{XR_NULL_HANDLE};
XrAction act_finger_press{XR_NULL_HANDLE};
XrAction act_pose{XR_NULL_HANDLE};
XrAction act_gaze{XR_NULL_HANDLE};
XrAction act_grip{XR_NULL_HANDLE};
XrAction act_rumble{XR_NULL_HANDLE};
XrPath hand_paths[2]{XR_NULL_PATH, XR_NULL_PATH};
XrSpace controller_space{XR_NULL_HANDLE};
XrSpace gaze_space{XR_NULL_HANDLE};
XrPath gaze_path{XR_NULL_PATH};
// Where each hand holds something, as far as the headset's runtime says where its own
// controllers are: with a gamepad in both hands and those controllers put away, a runtime
// that makes controllers up from the hands it sees (Virtual Desktop does) says where the
@@ -549,6 +557,10 @@ struct OpenXrHost::Impl {
return offered_one;
};
hand_extension = track_hands && enable_if_offered(XR_EXT_HAND_TRACKING_EXTENSION_NAME);
const char* foveation_env = std::getenv("SHADPS4_VR_FOVEATION");
const bool foveation_requested = foveation_env != nullptr && foveation_env[0] == '1';
gaze_extension =
foveation_requested && enable_if_offered(XR_EXT_EYE_GAZE_INTERACTION_EXTENSION_NAME);
has_refresh_rate = enable_if_offered(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
has_frame_controller = enable_if_offered("XR_VALVE_frame_controller_interaction");
#ifdef _WIN32
@@ -961,6 +973,10 @@ struct OpenXrHost::Impl {
made = made && make(act_square, "square", "Square", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_circle, "circle", "Circle", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_triangle, "triangle", "Triangle", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_up, "dpad_up", "D-pad up", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_down, "dpad_down", "D-pad down", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_left, "dpad_left", "D-pad left", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_right, "dpad_right", "D-pad right", XR_ACTION_TYPE_BOOLEAN_INPUT);
made = made && make(act_l1, "l1", "L1", XR_ACTION_TYPE_FLOAT_INPUT);
made = made && make(act_r1, "r1", "R1", XR_ACTION_TYPE_FLOAT_INPUT);
made = made && make(act_l2, "l2", "L2", XR_ACTION_TYPE_FLOAT_INPUT);
@@ -971,6 +987,18 @@ struct OpenXrHost::Impl {
made = made && make(act_grip, "hands", "Hands", XR_ACTION_TYPE_POSE_INPUT, true);
made = made &&
make(act_rumble, "rumble", "Rumble", XR_ACTION_TYPE_VIBRATION_OUTPUT, true);
if (gaze_extension) {
xrStringToPath(instance, "/user/eyes_ext", &gaze_path);
XrActionCreateInfo gaze_info{XR_TYPE_ACTION_CREATE_INFO};
std::strcpy(gaze_info.actionName, "eye_gaze");
std::strcpy(gaze_info.localizedActionName, "Eye gaze");
gaze_info.actionType = XR_ACTION_TYPE_POSE_INPUT;
gaze_info.countSubactionPaths = 1;
gaze_info.subactionPaths = &gaze_path;
if (XR_FAILED(xrCreateAction(action_set, &gaze_info, &act_gaze))) {
gaze_extension = false;
}
}
if (!made) {
LOG_WARNING(Core_Vr, "The headset's controllers could not be set up as a gamepad");
return;
@@ -1002,6 +1030,12 @@ struct OpenXrHost::Impl {
bind(act_triangle,
frame ? "/user/hand/right/input/y/click"
: index ? "/user/hand/left/input/b/click" : "/user/hand/left/input/y/click");
if (frame) {
bind(act_up, "/user/hand/left/input/dpad_up/click");
bind(act_down, "/user/hand/left/input/dpad_down/click");
bind(act_left, "/user/hand/left/input/dpad_left/click");
bind(act_right, "/user/hand/left/input/dpad_right/click");
}
bind(act_l1, "/user/hand/left/input/squeeze/value");
bind(act_r1, "/user/hand/right/input/squeeze/value");
bind(act_l2, "/user/hand/left/input/trigger/value");
@@ -1036,6 +1070,22 @@ struct OpenXrHost::Impl {
}
}
if (gaze_extension) {
XrPath profile{XR_NULL_PATH};
XrPath gaze_pose{XR_NULL_PATH};
xrStringToPath(instance, "/interaction_profiles/ext/eye_gaze_interaction", &profile);
xrStringToPath(instance, "/user/eyes_ext/input/gaze_ext/pose", &gaze_pose);
const XrActionSuggestedBinding binding{act_gaze, gaze_pose};
XrInteractionProfileSuggestedBinding suggested{
XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggested.interactionProfile = profile;
suggested.countSuggestedBindings = 1;
suggested.suggestedBindings = &binding;
if (XR_FAILED(xrSuggestInteractionProfileBindings(instance, &suggested))) {
gaze_extension = false;
}
}
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
attach.countActionSets = 1;
attach.actionSets = &action_set;
@@ -1053,6 +1103,14 @@ struct OpenXrHost::Impl {
LOG_WARNING(Core_Vr, "The headset's controllers cannot be located");
return;
}
if (gaze_extension) {
space_info.action = act_gaze;
space_info.subactionPath = gaze_path;
if (XR_FAILED(xrCreateActionSpace(session, &space_info, &gaze_space))) {
gaze_space = XR_NULL_HANDLE;
gaze_extension = false;
}
}
for (int hand = 0; hand < 2; ++hand) {
space_info.action = act_grip;
space_info.subactionPath = hand_paths[hand];
@@ -1091,6 +1149,7 @@ struct OpenXrHost::Impl {
void UpdateControllers(XrTime time) {
actions_synced = false;
if (!actions_ready) {
Runtime::Instance().SetGazeTarget({});
return;
}
const XrActiveActionSet active{action_set, XR_NULL_PATH};
@@ -1099,11 +1158,48 @@ struct OpenXrHost::Impl {
sync.activeActionSets = &active;
// (Anything but plain success: the game does not have the player's attention.)
if (xrSyncActions(session, &sync) != XR_SUCCESS) {
Runtime::Instance().SetGazeTarget({});
ReleaseControllers("the game is not what the player has in front of them");
ApplyRumble(false);
return;
}
actions_synced = true;
GazeTarget gaze{};
if (gaze_extension && gaze_space != XR_NULL_HANDLE) {
XrActionStateGetInfo get_gaze{XR_TYPE_ACTION_STATE_GET_INFO};
get_gaze.action = act_gaze;
get_gaze.subactionPath = gaze_path;
XrActionStatePose state{XR_TYPE_ACTION_STATE_POSE};
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
if (XR_SUCCEEDED(xrGetActionStatePose(session, &get_gaze, &state)) &&
state.isActive == XR_TRUE &&
XR_SUCCEEDED(xrLocateSpace(gaze_space, view_space, time, &location)) &&
(location.locationFlags & (XR_SPACE_LOCATION_POSITION_VALID_BIT |
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT |
XR_SPACE_LOCATION_POSITION_TRACKED_BIT |
XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT)) ==
(XR_SPACE_LOCATION_POSITION_VALID_BIT |
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT |
XR_SPACE_LOCATION_POSITION_TRACKED_BIT |
XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT)) {
const Quat orientation{location.pose.orientation.x, location.pose.orientation.y,
location.pose.orientation.z, location.pose.orientation.w};
const Vec3 direction = Rotate(orientation, {0.0f, 0.0f, -1.0f});
if (direction.z < -0.05f) {
const Fov fov = Runtime::Instance().TitleFov();
const float tan_x = direction.x / -direction.z;
const float tan_y = direction.y / -direction.z;
const float local_x = (tan_x + fov.tan_out) / (fov.tan_out + fov.tan_in);
gaze.left_x = std::clamp(local_x, 0.0f, 1.0f) * 0.5f;
gaze.right_x = 0.5f + std::clamp(local_x, 0.0f, 1.0f) * 0.5f;
gaze.y = std::clamp((fov.tan_top - tan_y) /
(fov.tan_top + fov.tan_bottom),
0.0f, 1.0f);
gaze.valid = true;
}
}
}
Runtime::Instance().SetGazeTarget(gaze);
if (!use_controllers) {
// Only where they are is of interest (see UpdatePad).
return;
@@ -1195,6 +1291,10 @@ struct OpenXrHost::Impl {
const bool blow = circle && triangle;
add(circle && !blow, Buttons::Circle);
add(triangle && !blow, Buttons::Triangle);
add(pressed(act_up), Buttons::Up);
add(pressed(act_down), Buttons::Down);
add(pressed(act_left), Buttons::Left);
add(pressed(act_right), Buttons::Right);
add(pressed(act_options), Buttons::Options);
const bool left_stick_in = pressed(act_l3);
const bool right_stick_in = pressed(act_finger_press);
@@ -1228,7 +1328,8 @@ struct OpenXrHost::Impl {
(True(buttons & Buttons::Square) ? 2u : 0u) | (circle ? 4u : 0u) |
(triangle ? 8u : 0u) | (left_stick_in ? 16u : 0u) |
(right_stick_in ? 32u : 0u) | (pushed(move) ? 64u : 0u) |
(pushed(finger) ? 128u : 0u);
(pushed(finger) ? 128u : 0u) |
((static_cast<u32>(buttons) & 0xf0u) << 4);
const bool used = (in_use & ~controllers_in_use) != 0;
controllers_in_use = in_use;
if (!Input::FirstPadSource().Update(seconds, gamepad, used)) {
@@ -1495,6 +1596,10 @@ struct OpenXrHost::Impl {
actions_ready = false;
actions_synced = false;
pad_source = 0;
if (gaze_space != XR_NULL_HANDLE) {
xrDestroySpace(gaze_space);
gaze_space = XR_NULL_HANDLE;
}
if (controller_space != XR_NULL_HANDLE) {
xrDestroySpace(controller_space);
controller_space = XR_NULL_HANDLE;
@@ -394,6 +394,21 @@ void Runtime::RequestRecenter() {
Libraries::SystemService::PushSystemServiceEvent(event);
}
void Runtime::SetGazeTarget(const GazeTarget& target) {
std::scoped_lock lock{mutex};
gaze_target = target;
gaze_target_time = std::chrono::steady_clock::now();
}
GazeTarget Runtime::GetGazeTarget() const {
std::scoped_lock lock{mutex};
GazeTarget target = gaze_target;
if (std::chrono::steady_clock::now() - gaze_target_time > std::chrono::milliseconds{100}) {
target.valid = false;
}
return target;
}
void Runtime::UpdateHead(const DeviceState& host_state) {
std::scoped_lock lock{mutex};
// How fast the head moves, by where it is from one time to the next: for a host that does
@@ -72,6 +72,13 @@ struct Fov {
float tan_bottom{1.262872f};
};
struct GazeTarget {
float left_x{0.25f};
float right_x{0.75f};
float y{0.5f};
bool valid{};
};
struct HeadsetIdentity {
std::string runtime;
std::string system;
@@ -166,6 +173,8 @@ public:
// wherever its system last put them. Where the player actually sits in it, and which way
// they face, is the "seat" (see RecenterSeat); the title is given poses relative to that.
void UpdateHead(const DeviceState& host_state);
void SetGazeTarget(const GazeTarget& target);
GazeTarget GetGazeTarget() const;
/// Declares the head's present position to be the resting one and the direction it looks
/// in to be straight ahead. A title places its world around where the player sat when it
/// last took stock (PlayStation VR: at its start, and when the player holds OPTIONS), and
@@ -300,6 +309,8 @@ private:
std::chrono::steady_clock::time_point gesture_options_since;
mutable std::mutex mutex;
DeviceState head;
GazeTarget gaze_target;
std::chrono::steady_clock::time_point gaze_target_time;
// What the host last said of the head, in its own space, and the seat in that space.
DeviceState host_head;
// Where the head was a moment ago, and how fast that says it moves.
@@ -353,8 +353,21 @@ GraphicsPipeline::GraphicsPipeline(
// In practice, we use dynamic state for all of it.
constexpr vk::PipelineDepthStencilStateCreateInfo depth_stencil_info = {};
const vk::GraphicsPipelineCreateInfo pipeline_info = {
// Without this the combiners default to KEEP and the foveation map is ignored.
const vk::PipelineFragmentShadingRateStateCreateInfoKHR shading_rate_state = {
.pNext = &pipeline_rendering_ci,
.fragmentSize = {1, 1},
.combinerOps = std::array{vk::FragmentShadingRateCombinerOpKHR::eKeep,
vk::FragmentShadingRateCombinerOpKHR::eReplace},
};
const vk::GraphicsPipelineCreateInfo pipeline_info = {
.pNext = instance.IsFragmentShadingRateSupported()
? static_cast<const void*>(&shading_rate_state)
: &pipeline_rendering_ci,
.flags = instance.IsFragmentShadingRateSupported()
? vk::PipelineCreateFlagBits::eRenderingFragmentShadingRateAttachmentKHR
: vk::PipelineCreateFlags{},
.stageCount = static_cast<u32>(shader_stages.size()),
.pStages = shader_stages.data(),
.pVertexInputState = !instance.IsVertexInputDynamicState() ? &vertex_input_info : nullptr,
@@ -233,6 +233,12 @@ std::string Instance::GetDriverVersionName() {
}
bool Instance::CreateDevice() {
const char* foveation_env = std::getenv("SHADPS4_VR_FOVEATION");
const bool foveation_requested = foveation_env != nullptr && foveation_env[0] == '1';
const bool shading_rate_extension =
foveation_requested &&
std::ranges::find(available_extensions, VK_KHR_FRAGMENT_SHADING_RATE_EXTENSION_NAME) !=
available_extensions.end();
const vk::StructureChain feature_chain =
physical_device
.getFeatures2<vk::PhysicalDeviceFeatures2, vk::PhysicalDeviceVulkan11Features,
@@ -244,6 +250,13 @@ bool Instance::CreateDevice() {
vk::PhysicalDeviceWorkgroupMemoryExplicitLayoutFeaturesKHR,
vk::PhysicalDeviceImage2DViewOf3DFeaturesEXT>();
features = feature_chain.get().features;
if (shading_rate_extension) {
const auto shading_rate_chain =
physical_device.getFeatures2<vk::PhysicalDeviceFeatures2,
vk::PhysicalDeviceFragmentShadingRateFeaturesKHR>();
fragment_shading_rate_features =
shading_rate_chain.get<vk::PhysicalDeviceFragmentShadingRateFeaturesKHR>();
}
LOG_INFO(Render_Vulkan, "GPU_ClipCull: clip={} cull={} combined={} featClip={} featCull={}",
properties.limits.maxClipDistances, properties.limits.maxCullDistances,
properties.limits.maxCombinedClipAndCullDistances,
@@ -258,6 +271,14 @@ bool Instance::CreateDevice() {
vk12_props = properties_chain.get<vk::PhysicalDeviceVulkan12Properties>();
vk13_props = properties_chain.get<vk::PhysicalDeviceVulkan13Properties>();
push_descriptor_props = properties_chain.get<vk::PhysicalDevicePushDescriptorPropertiesKHR>();
if (shading_rate_extension) {
const auto shading_rate_properties_chain =
physical_device.getProperties2<vk::PhysicalDeviceProperties2,
vk::PhysicalDeviceFragmentShadingRatePropertiesKHR>();
fragment_shading_rate_properties =
shading_rate_properties_chain
.get<vk::PhysicalDeviceFragmentShadingRatePropertiesKHR>();
}
LOG_INFO(Render_Vulkan, "Physical device subgroup size {}", vk11_props.subgroupSize);
if (available_extensions.empty()) {
@@ -298,6 +319,49 @@ bool Instance::CreateDevice() {
// (step-rate) vertex fetch silently degrades to per-vertex (divisor=1). Per-vertex
// attributes are unaffected. Mali/Immortalis drivers do not expose this extension.
vertex_attribute_divisor = add_extension(VK_EXT_VERTEX_ATTRIBUTE_DIVISOR_EXTENSION_NAME);
bool shading_rate_levels_supported = false;
vk::ImageFormatProperties shading_rate_image_properties{};
if (shading_rate_extension && fragment_shading_rate_features.attachmentFragmentShadingRate) {
u32 count{};
if (physical_device.getFragmentShadingRatesKHR(&count, nullptr) == vk::Result::eSuccess) {
std::vector<vk::PhysicalDeviceFragmentShadingRateKHR> rates(count);
if (physical_device.getFragmentShadingRatesKHR(&count, rates.data()) ==
vk::Result::eSuccess) {
const auto supports = [&](vk::Extent2D rate) {
return std::ranges::any_of(rates, [&](const auto& supported) {
return supported.fragmentSize == rate &&
static_cast<bool>(supported.sampleCounts &
vk::SampleCountFlagBits::e1);
});
};
shading_rate_levels_supported =
supports({1, 1}) && supports({2, 2}) && supports({4, 4});
}
}
const auto [format_result, format_properties] =
physical_device.getImageFormatProperties(
vk::Format::eR8Uint, vk::ImageType::e2D, vk::ImageTiling::eOptimal,
vk::ImageUsageFlagBits::eFragmentShadingRateAttachmentKHR |
vk::ImageUsageFlagBits::eTransferDst,
{});
if (format_result == vk::Result::eSuccess) {
shading_rate_image_properties = format_properties;
}
}
fragment_shading_rate =
shading_rate_extension && fragment_shading_rate_features.attachmentFragmentShadingRate &&
shading_rate_levels_supported &&
add_extension(VK_KHR_FRAGMENT_SHADING_RATE_EXTENSION_NAME) &&
shading_rate_image_properties.maxExtent.width >= 4096 &&
shading_rate_image_properties.maxExtent.height >= 4096;
if (fragment_shading_rate) {
shading_rate_texel_size = fragment_shading_rate_properties
.minFragmentShadingRateAttachmentTexelSize;
} else if (foveation_requested) {
LOG_WARNING(Render_Vulkan,
"Foveation requested but fragment shading-rate attachments are unavailable; "
"using full-rate rendering");
}
// Optional
maintenance_8 = add_extension(VK_KHR_MAINTENANCE_8_EXTENSION_NAME);
@@ -591,11 +655,17 @@ bool Instance::CreateDevice() {
.image2DViewOf3D = image_2d_view_of_3d_features.image2DViewOf3D,
.sampler2DViewOf3D = image_2d_view_of_3d_features.sampler2DViewOf3D,
},
vk::PhysicalDeviceFragmentShadingRateFeaturesKHR{
.attachmentFragmentShadingRate = fragment_shading_rate,
},
};
if (!custom_border_color) {
device_chain.unlink<vk::PhysicalDeviceCustomBorderColorFeaturesEXT>();
}
if (!fragment_shading_rate) {
device_chain.unlink<vk::PhysicalDeviceFragmentShadingRateFeaturesKHR>();
}
if (!dynamic_state_3) {
device_chain.unlink<vk::PhysicalDeviceExtendedDynamicState3FeaturesEXT>();
}
@@ -53,6 +53,14 @@ public:
return allocator;
}
bool IsFragmentShadingRateSupported() const {
return fragment_shading_rate;
}
vk::Extent2D FragmentShadingRateTexelSize() const {
return shading_rate_texel_size;
}
/// Returns a list of the available physical devices
std::span<const vk::PhysicalDevice> GetPhysicalDevices() const {
return physical_devices;
@@ -532,6 +540,10 @@ private:
vk::PhysicalDeviceWorkgroupMemoryExplicitLayoutFeaturesKHR
workgroup_memory_explicit_layout_features;
vk::PhysicalDeviceImage2DViewOf3DFeaturesEXT image_2d_view_of_3d_features;
vk::PhysicalDeviceFragmentShadingRateFeaturesKHR fragment_shading_rate_features;
vk::PhysicalDeviceFragmentShadingRatePropertiesKHR fragment_shading_rate_properties;
vk::Extent2D shading_rate_texel_size{8, 8};
bool fragment_shading_rate{};
vk::PhysicalDevicePrimitiveTopologyListRestartFeaturesEXT list_restart_features;
vk::DriverIdKHR driver_id;
vk::UniqueDebugUtilsMessengerEXT debug_callback{};
@@ -8,9 +8,17 @@
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <utility>
#include "core/known_title.h"
#include "core/vr/foveation_map.h"
#include "core/vr/openxr_host.h"
#include "core/vr/vr_runtime.h"
#include "video_core/buffer_cache/buffer.h"
#include "video_core/texture_cache/image.h"
#ifdef _WIN32
#include <windows.h>
#endif
@@ -23,8 +31,30 @@
#include "video_core/renderer_vulkan/vk_instance.h"
#include "video_core/renderer_vulkan/vk_scheduler.h"
#include <vk_mem_alloc.h>
namespace Vulkan {
struct FoveationState {
struct RetiredUploads {
u64 tick;
std::vector<VideoCore::UniqueBuffer> buffers;
};
VideoCore::UniqueImage image;
vk::UniqueImageView view;
std::vector<VideoCore::UniqueBuffer> uploads;
std::vector<RetiredUploads> retired;
vk::Extent2D texel_size{};
u32 image_width{4096};
u32 image_height{4096};
bool image_initialized{};
float last_left_x{-1.0f};
float last_right_x{-1.0f};
float last_y{-1.0f};
u16 last_width{};
u16 last_height{};
};
constexpr u64 MAX_IN_FLIGHT_SUBMISSIONS = 8;
namespace {
@@ -481,7 +511,144 @@ void Scheduler::BeginRendering(const RenderState& new_state) {
vk::ClearDepthStencilValue{.stencil = db.clear_value[1]}},
};
vk::RenderingFragmentShadingRateAttachmentInfoKHR shading_rate_attachment{
.imageView = {},
.imageLayout = vk::ImageLayout::eFragmentShadingRateAttachmentOptimalKHR,
.shadingRateAttachmentTexelSize = {},
};
const vk::RenderingFragmentShadingRateAttachmentInfoKHR* shading_rate_info = nullptr;
static bool warned_invalid_gaze = false;
// The title draws its scene one eye at a time: only that pass is foveated.
const auto [scene_width, scene_height] = Core::KnownTitle::SceneEyeSize();
const bool foveation_pass = instance.IsFragmentShadingRateSupported() &&
Core::Vr::OpenXrHost::Instance().IsAvailable() &&
new_state.num_layers == 1 && new_state.width == scene_width &&
new_state.height == scene_height;
if (foveation_pass) {
const Core::Vr::GazeTarget gaze = Core::Vr::Runtime::Instance().GetGazeTarget();
if (gaze.valid) {
if (!foveation) {
foveation = std::make_unique<FoveationState>();
foveation->texel_size = instance.FragmentShadingRateTexelSize();
foveation->image = VideoCore::UniqueImage{instance.GetDevice(),
instance.GetAllocator()};
foveation->image.Create(vk::ImageCreateInfo{
.imageType = vk::ImageType::e2D,
.format = vk::Format::eR8Uint,
.extent = {foveation->image_width, foveation->image_height, 1},
.mipLevels = 1,
.arrayLayers = 1,
.samples = vk::SampleCountFlagBits::e1,
.tiling = vk::ImageTiling::eOptimal,
.usage = vk::ImageUsageFlagBits::eFragmentShadingRateAttachmentKHR |
vk::ImageUsageFlagBits::eTransferDst,
.initialLayout = vk::ImageLayout::eUndefined,
});
auto [view_result, view] =
instance.GetDevice().createImageViewUnique(vk::ImageViewCreateInfo{
.image = foveation->image,
.viewType = vk::ImageViewType::e2D,
.format = vk::Format::eR8Uint,
.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1},
});
ASSERT_MSG(view_result == vk::Result::eSuccess,
"Unable to create foveation-rate view: {}", vk::to_string(view_result));
foveation->view = std::move(view);
LOG_INFO(Render_Vulkan,
"Eye-tracked foveation map active ({}x{} texels; 1x1 to 10 degrees, "
"2x2 to 35 degrees, then 4x4)",
foveation->texel_size.width, foveation->texel_size.height);
}
if (foveation->last_width != new_state.width ||
foveation->last_height != new_state.height ||
foveation->last_left_x != gaze.left_x ||
foveation->last_right_x != gaze.right_x || foveation->last_y != gaze.y) {
const auto rates = Core::Vr::BuildFoveationMap(
new_state.width, new_state.height, foveation->texel_size.width,
foveation->texel_size.height, Core::Vr::Runtime::Instance().TitleFov(), gaze,
false);
VideoCore::UniqueBuffer upload{instance.GetDevice(), instance.GetAllocator()};
upload.Create(vk::BufferCreateInfo{
.size = rates.size(),
.usage = vk::BufferUsageFlagBits::eTransferSrc,
},
VideoCore::MemoryUsage::Upload, nullptr);
void* mapped{};
ASSERT_MSG(vmaMapMemory(instance.GetAllocator(), upload.allocation, &mapped) ==
VK_SUCCESS,
"Unable to map foveation-rate upload buffer");
std::memcpy(mapped, rates.data(), rates.size());
const VkResult flush_result =
vmaFlushAllocation(instance.GetAllocator(), upload.allocation, 0, rates.size());
ASSERT_MSG(flush_result == VK_SUCCESS, "Foveation upload flush failed: {}",
vk::to_string(vk::Result{flush_result}));
vmaUnmapMemory(instance.GetAllocator(), upload.allocation);
const u32 map_width =
(new_state.width + foveation->texel_size.width - 1) /
foveation->texel_size.width;
const u32 map_height =
(new_state.height + foveation->texel_size.height - 1) /
foveation->texel_size.height;
const vk::ImageMemoryBarrier to_transfer{
.srcAccessMask = foveation->image_initialized
? vk::AccessFlagBits::eFragmentShadingRateAttachmentReadKHR
: vk::AccessFlags{},
.dstAccessMask = vk::AccessFlagBits::eTransferWrite,
.oldLayout = foveation->image_initialized
? vk::ImageLayout::eFragmentShadingRateAttachmentOptimalKHR
: vk::ImageLayout::eUndefined,
.newLayout = vk::ImageLayout::eTransferDstOptimal,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = foveation->image,
.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1},
};
current_cmdbuf.pipelineBarrier(
foveation->image_initialized
? vk::PipelineStageFlagBits::eFragmentShadingRateAttachmentKHR
: vk::PipelineStageFlagBits::eTopOfPipe,
vk::PipelineStageFlagBits::eTransfer, {}, {}, {}, to_transfer);
current_cmdbuf.copyBufferToImage(
upload, foveation->image, vk::ImageLayout::eTransferDstOptimal,
vk::BufferImageCopy{
.imageSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1},
.imageExtent = {map_width, map_height, 1},
});
const vk::ImageMemoryBarrier to_attachment{
.srcAccessMask = vk::AccessFlagBits::eTransferWrite,
.dstAccessMask = vk::AccessFlagBits::eFragmentShadingRateAttachmentReadKHR,
.oldLayout = vk::ImageLayout::eTransferDstOptimal,
.newLayout = vk::ImageLayout::eFragmentShadingRateAttachmentOptimalKHR,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = foveation->image,
.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1},
};
current_cmdbuf.pipelineBarrier(
vk::PipelineStageFlagBits::eTransfer,
vk::PipelineStageFlagBits::eFragmentShadingRateAttachmentKHR, {}, {}, {},
to_attachment);
foveation->image_initialized = true;
foveation->last_width = new_state.width;
foveation->last_height = new_state.height;
foveation->last_left_x = gaze.left_x;
foveation->last_right_x = gaze.right_x;
foveation->last_y = gaze.y;
foveation->uploads.push_back(std::move(upload));
}
shading_rate_attachment.imageView = *foveation->view;
shading_rate_attachment.shadingRateAttachmentTexelSize = foveation->texel_size;
shading_rate_info = &shading_rate_attachment;
} else if (!warned_invalid_gaze) {
LOG_WARNING(Render_Vulkan,
"Eye gaze is unavailable or stale; rendering at full fragment rate");
warned_invalid_gaze = true;
}
}
const vk::RenderingInfo rendering_info = {
.pNext = shading_rate_info,
.renderArea =
{
.offset = {0, 0},
@@ -558,6 +725,11 @@ void Scheduler::PopPendingOperations() {
pending_ops.front().callback();
pending_ops.pop();
}
if (foveation) {
std::erase_if(foveation->retired, [this](const auto& upload) {
return master_semaphore.IsFree(upload.tick);
});
}
}
void Scheduler::AllocateWorkerCommandBuffers() {
@@ -679,6 +851,10 @@ void Scheduler::SubmitExecution(SubmitInfo& info) {
LOG_CRITICAL(Render_Vulkan, "Queue submission {} failed: {}", signal_value,
vk::to_string(submit_result));
}
if (foveation && !foveation->uploads.empty()) {
foveation->retired.push_back({signal_value, std::move(foveation->uploads)});
foveation->uploads.clear();
}
AllocateWorkerCommandBuffers();
@@ -6,6 +6,7 @@
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <memory>
#include <thread>
#include <queue>
#include <source_location>
@@ -22,6 +23,8 @@ class VkCtxScope;
namespace Vulkan {
struct FoveationState;
class Instance;
struct RenderAttachment {
@@ -543,6 +546,7 @@ private:
/// pipelines of its own, always in a pass of its own: nothing is assumed across passes.
vk::Pipeline bound_pipeline{};
tracy::VkCtxScope* profiler_scope{};
std::unique_ptr<FoveationState> foveation;
};
} // namespace Vulkan
-251
View File
@@ -1,251 +0,0 @@
# Takes a playback device out of Windows and puts it back (what the Sound control panel's
# "Disable" does; no administrator needed), and measures what is being played on one. For
# testing how the emulator copes with a sound device that goes away while it plays.
# audio-endpoint.ps1 list
# audio-endpoint.ps1 hide <name>
# audio-endpoint.ps1 show <name>
# audio-endpoint.ps1 peak <name> [seconds] highest level played in that time, 0..1
# audio-endpoint.ps1 default the name of the device Windows plays on
# audio-endpoint.ps1 default <name> makes that one the device Windows plays on
# (what a program that streams to a headset
# does when the headset connects)
# audio-endpoint.ps1 preferred the device Windows would play on if every
# device were there
# Mind what "default <name>" leaves behind: Windows remembers the order in which devices were
# chosen, and plays on the one chosen last of those that are there. A device that is not
# there just now (a headset's, while nothing streams to it) is behind the one chosen with
# this from then on, and Windows no longer changes over to it when it comes back. Choose it
# again afterwards, there or not: tools/pc-audio-default-test.sh does.
param([Parameter(Mandatory = $true)][string]$Action, [string]$Name, [double]$Seconds = 2)
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace AudioEndpoint {
[StructLayout(LayoutKind.Sequential)]
public struct PropertyKey { public Guid fmtid; public int pid; }
[StructLayout(LayoutKind.Explicit)]
public struct PropVariant {
[FieldOffset(0)] public ushort vt;
[FieldOffset(8)] public IntPtr pointer;
[FieldOffset(16)] public IntPtr filler;
}
[ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IPropertyStore {
int GetCount(out int count);
int GetAt(int index, out PropertyKey key);
int GetValue(ref PropertyKey key, out PropVariant value);
}
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IMMDevice {
int Activate(ref Guid iid, int context, IntPtr parameters,
[MarshalAs(UnmanagedType.IUnknown)] out object result);
int OpenPropertyStore(int access, out IPropertyStore properties);
int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id);
int GetState(out int state);
}
[ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IMMDeviceCollection {
int GetCount(out int count);
int Item(int index, out IMMDevice device);
}
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IMMDeviceEnumerator {
int EnumAudioEndpoints(int flow, int states, out IMMDeviceCollection devices);
[PreserveSig]
int GetDefaultAudioEndpoint(int flow, int role, out IMMDevice device);
}
[ComImport, Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IAudioMeterInformation {
int GetPeakValue(out float peak);
}
[ComImport, Guid("f8679f50-850a-41cf-9c72-430f290290c8"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IPolicyConfig {
int GetMixFormat();
int GetDeviceFormat();
int ResetDeviceFormat();
int SetDeviceFormat();
int GetProcessingPeriod();
int SetProcessingPeriod();
int GetShareMode();
int SetShareMode();
int GetPropertyValue();
int SetPropertyValue();
[PreserveSig]
int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string id, int role);
[PreserveSig]
int SetEndpointVisibility([MarshalAs(UnmanagedType.LPWStr)] string id, int visible);
}
public class Device {
public string Id;
public string Name;
public int State;
}
public static class Endpoints {
static readonly Guid EnumeratorClass = new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E");
static readonly Guid PolicyClass = new Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9");
static IMMDeviceEnumerator Enumerator() {
return (IMMDeviceEnumerator)Activator.CreateInstance(
Type.GetTypeFromCLSID(EnumeratorClass));
}
static IMMDevice Find(string name, out Device found) {
IMMDeviceCollection devices;
// Playback devices in any state but "not present".
Marshal.ThrowExceptionForHR(Enumerator().EnumAudioEndpoints(0, 0x3, out devices));
int count;
devices.GetCount(out count);
found = null;
for (int i = 0; i < count; ++i) {
IMMDevice device;
devices.Item(i, out device);
Device described = Describe(device);
if (name == null || described.Name == name) {
found = described;
if (name != null) {
return device;
}
}
}
return null;
}
static Device Describe(IMMDevice device) {
Device described = new Device();
device.GetId(out described.Id);
device.GetState(out described.State);
IPropertyStore properties;
if (device.OpenPropertyStore(0, out properties) == 0) {
PropertyKey key = new PropertyKey {
fmtid = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"), pid = 14 };
PropVariant value;
if (properties.GetValue(ref key, out value) == 0 && value.vt == 31) {
described.Name = Marshal.PtrToStringUni(value.pointer);
}
}
return described;
}
public static List<Device> List() {
List<Device> all = new List<Device>();
IMMDeviceCollection devices;
Marshal.ThrowExceptionForHR(Enumerator().EnumAudioEndpoints(0, 0x3, out devices));
int count;
devices.GetCount(out count);
for (int i = 0; i < count; ++i) {
IMMDevice device;
devices.Item(i, out device);
all.Add(Describe(device));
}
return all;
}
public static void SetVisible(string name, bool visible) {
Device found;
if (Find(name, out found) == null) {
throw new Exception("no playback device named " + name);
}
IPolicyConfig policy =
(IPolicyConfig)Activator.CreateInstance(Type.GetTypeFromCLSID(PolicyClass));
Marshal.ThrowExceptionForHR(policy.SetEndpointVisibility(found.Id, visible ? 1 : 0));
}
public static string Default() {
IMMDevice device;
if (Enumerator().GetDefaultAudioEndpoint(0, 0, out device) != 0 || device == null) {
return "";
}
return Describe(device).Name;
}
public static void SetDefault(string name) {
Device found;
if (Find(name, out found) == null) {
throw new Exception("no playback device named " + name);
}
IPolicyConfig policy =
(IPolicyConfig)Activator.CreateInstance(Type.GetTypeFromCLSID(PolicyClass));
// For everything it can be the default of: console, multimedia, communications.
for (int role = 0; role < 3; ++role) {
Marshal.ThrowExceptionForHR(policy.SetDefaultEndpoint(found.Id, role));
}
}
public static float Peak(string name, double seconds) {
Device found;
IMMDevice device = Find(name, out found);
if (device == null) {
throw new Exception("no playback device named " + name);
}
Guid iid = typeof(IAudioMeterInformation).GUID;
object meter_object;
Marshal.ThrowExceptionForHR(device.Activate(ref iid, 23, IntPtr.Zero, out meter_object));
IAudioMeterInformation meter = (IAudioMeterInformation)meter_object;
float highest = 0;
DateTime end = DateTime.UtcNow.AddSeconds(seconds);
while (DateTime.UtcNow < end) {
float peak;
meter.GetPeakValue(out peak);
highest = Math.Max(highest, peak);
System.Threading.Thread.Sleep(20);
}
return highest;
}
}
}
'@
switch ($Action) {
'list' {
[AudioEndpoint.Endpoints]::List() | ForEach-Object {
'{0} {1}' -f $(if ($_.State -eq 1) { 'active ' } else { 'disabled' }), $_.Name
}
}
'hide' { [AudioEndpoint.Endpoints]::SetVisible($Name, $false) }
'show' { [AudioEndpoint.Endpoints]::SetVisible($Name, $true) }
'peak' { '{0:0.0000}' -f [AudioEndpoint.Endpoints]::Peak($Name, $Seconds) }
'default' {
if ($Name) { [AudioEndpoint.Endpoints]::SetDefault($Name) }
[AudioEndpoint.Endpoints]::Default()
}
'preferred' {
# Windows counts the choices: the highest count among the devices that are plugged
# in (in use or taken out of the list) is the one it would play on.
$base = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render"
$best = $null
foreach ($key in Get-ChildItem $base -ErrorAction SilentlyContinue) {
$item = Get-Item $key.PSPath
$state = $item.GetValue("DeviceState")
$level = $item.GetValue("Level:0")
if ($null -eq $level -or ($state -band 0xF) -ne 1) { continue }
if ($null -eq $best -or $level -gt $best.Level) {
$properties = Get-ItemProperty (Join-Path $key.PSPath "Properties") -ErrorAction SilentlyContinue
$best = [pscustomobject]@{
Level = $level
Name = ('{0} ({1})' -f $properties.'{a45c254e-df1c-4efd-8020-67d146a850e0},2',
$properties.'{b3f8fa53-0004-438e-9003-51a46e139bfc},6')
}
}
}
if ($best) { $best.Name }
}
default { throw "unknown action $Action" }
}
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Cross-compiles the emulator core for the Quest (aarch64 Linux/glibc, FEX guest CPU).
# tools/build-arm64.sh configure if needed, then build
# Needs tools/sysroot-arm64 (tools/mk-sysroot.mjs), FEXCore built in
# shadps4-arm64-main/runtime/build/fexcore-smoke-build and the Windows build's font embed tool.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$root/tools/env-cross.sh"
R=$(cygpath -m "$root/shadps4-arm64-main")
SR=$(cygpath -m "$root/tools/sysroot-arm64")
B=$(cygpath -m "$root/build/arm64")
if [[ ! -f "$B/build.ninja" ]]; then
cmake -S "$R" -B "$B" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$(cygpath -m "$root/tools/toolchain-aarch64-linux.cmake")" \
-DCMAKE_INSTALL_PREFIX=/usr \
-DX11_Xext_LIB:FILEPATH="$SR/usr/lib/aarch64-linux-gnu/libXext.so" \
-DXEXT_LIB:FILEPATH="$SR/usr/lib/aarch64-linux-gnu/libXext.so" \
-DIMGUI_FONT_EMBED_EXECUTABLE="$(cygpath -m "$root/build/win-x64/src/imgui/renderer/Dear_ImGui_FontEmbed.exe")" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_SCAN_FOR_MODULES=OFF \
-DENABLE_BACHATA_RUNTIME=ON -DENABLE_FEX_GUEST_CPU=ON \
-DFEXCORE_GUEST_CPU_SOURCE_DIR="$R/runtime/sources/fex" \
-DFEXCORE_GUEST_CPU_BUILD_DIR="$R/runtime/build/fexcore-smoke-build" \
-DENABLE_USERFAULTFD=OFF -DENABLE_DISCORD_RPC=OFF -DENABLE_UPDATER=OFF -DENABLE_TESTS=OFF \
-DSDL_X11_XCURSOR=OFF -DSDL_X11_XDBE=OFF -DSDL_X11_XINPUT=OFF -DSDL_X11_XFIXES=OFF \
-DSDL_X11_XRANDR=OFF -DSDL_X11_XSCRNSAVER=OFF -DSDL_X11_XSHAPE=OFF -DSDL_X11_XSYNC=OFF \
-DSDL_X11_XTEST=OFF -DSDL_WAYLAND=OFF \
> "$(cygpath -m "$root/build/arm64-configure.log")" 2>&1 || { echo "configure failed, see build/arm64-configure.log"; exit 1; }
fi
cmake --build "$B" --target shadps4 > "$(cygpath -m "$root/build/arm64-build.log")" 2>&1
status=$?
echo "build exit=$status"
grep -a -n "error:" "$(cygpath -m "$root/build/arm64-build.log")" | head -20
tail -2 "$(cygpath -m "$root/build/arm64-build.log")" | cut -c1-300
exit $status
+2 -1
View File
@@ -46,8 +46,9 @@ mkdir -p "$stage"
install -m755 "$root/build/frame/shadps4" "$stage/astroquest"
strip "$stage/astroquest"
install -m755 "$root/tools/launch-frame.sh" "$stage/launch.sh"
install -m755 "$root/tools/install-frame.sh" "$stage/install.sh"
cp "$root/LICENSE" "$root/THIRD-PARTY-NOTICES.md" "$root/README-FRAME.md" \
"$root/README-PC-VR.md" "$stage/"
"$root/README.md" "$stage/"
while IFS= read -r -d '' notice; do
destination="$stage/licenses/${notice#"$source_dir/"}"
mkdir -p "$(dirname "$destination")"
-10
View File
@@ -1,10 +0,0 @@
# Source in Git Bash before cross-compiling for the Quest (aarch64 Linux): portable cmake, ninja
# and clang, plus a python3 shim because FEX's generators call "python3" by name.
tools_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# The folder of the Python named in tools/python.local (a full path), if there is one.
python_dir=$(cat "$tools_dir/python.local" 2>/dev/null)
python_dir=${python_dir:+$(cygpath -u "$(dirname "$python_dir")"):}
export PATH="$tools_dir/shims:$tools_dir/cmake/bin:$tools_dir/ninja:$tools_dir/llvm/bin:$python_dir$PATH"
unset INCLUDE LIB
export MSYS2_ARG_CONV_EXCL='*'
export MSYS_NO_PATHCONV=1
+1 -1
View File
@@ -3,7 +3,7 @@
The driver writes the trace when the core runs with
MESA_GPU_TRACES=print MESA_GPU_TRACEFILE=<file>
(tools/quest-sandbox-test.sh passes such settings through). Every line is an event with the time
(MESA_GPU_TRACEFILE selects the output file). Every line is an event with the time
the GPU reached it, in nanoseconds. This groups the render passes by what they draw to and how
the driver chose to render them, and prints the milliseconds each group costs per frame.
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
package=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
[[ $(uname -m) == aarch64 && $(uname -s) == Linux ]] || {
echo "Run install.sh on the Steam Frame itself." >&2; exit 1;
}
[[ $EUID != 0 ]] || { echo "Run as your Steam user, without sudo." >&2; exit 1; }
[[ $# -le 1 ]] || { echo "Usage: $0 [extracted game folder]" >&2; exit 1; }
exec python3 - "$package" "$@" <<'PY'
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
from urllib.parse import urlencode
package = Path(sys.argv[1])
home = Path.home()
app = home / 'Applications/astroquest-frame'
gameid = 'AstroQuest_Frame'
devkit = home / 'devkit-game'
def fail(message):
sys.exit(message)
required = ('astroquest', 'launch.sh', 'install.sh', 'LICENSE', 'THIRD-PARTY-NOTICES.md')
if not all((package / name).is_file() for name in required):
fail('Extract the complete release archive, then run its install.sh.')
with (package / 'astroquest').open('rb') as binary:
header = binary.read(20)
if header[:4] != b'\x7fELF' or int.from_bytes(header[18:20], 'little') != 183:
fail('The package does not contain an ARM64 Linux executable.')
game = Path(sys.argv[2]).expanduser().resolve() if len(sys.argv) == 3 else None
if game and not all((game / name).is_file() for name in ('eboot.bin', 'sce_sys/param.sfo')):
fail('The game folder must contain eboot.bin and sce_sys/param.sfo.')
for helpers in (Path('/usr/share/steamos-devkit/hooks'), home / 'devkit-utils'):
if (helpers / 'devkit_utils/__init__.py').is_file():
sys.path.insert(0, str(helpers))
break
else:
fail('SteamOS devkit helpers are missing. Enable Developer Mode on the Frame.')
import devkit_utils
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException:
fail('Start Steam on the Frame, then rerun install.sh as your normal Steam user.')
runtime = home / '.local/share/Steam/steamapps/common/SteamLinuxRuntime_4-arm64/run'
if not os.access(runtime, os.X_OK):
subprocess.run(['steam', 'steam://install/4185400'], check=True)
fail('Finish installing Steam Linux Runtime 4.0 - Arm64 in the headset, then rerun install.sh.')
entry = devkit / gameid
if (entry.exists() or entry.is_symlink()) and entry.resolve() != app.resolve():
fail(f'{entry} already points to another installation; it was left untouched.')
app.mkdir(parents=True, exist_ok=True)
if package.resolve() != app.resolve():
for source in package.iterdir():
if source.name not in required + ('README.md', 'README-FRAME.md', 'needed.txt', 'licenses'):
continue
destination = app / source.name
if source.is_dir():
shutil.copytree(source, destination, dirs_exist_ok=True)
else:
# Replace running executables by rename so updates leave their mapped inode intact.
fd, temporary = tempfile.mkstemp(prefix='.install-', dir=app)
os.close(fd)
try:
shutil.copy2(source, temporary)
os.replace(temporary, destination)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
for name in ('astroquest', 'launch.sh', 'install.sh'):
(app / name).chmod(0o755)
devkit.mkdir(exist_ok=True)
if not entry.is_symlink() and not entry.exists():
entry.symlink_to(app, target_is_directory=True)
argv_path = devkit / f'{gameid}-argv.json'
new_shortcut = not argv_path.exists()
if game or not argv_path.exists():
devkit_utils.save_argv(gameid, ['./launch.sh'] + ([shlex.quote(str(game))] if game else []))
devkit_utils.save_settings(gameid, {'settings': {'steam_play': '0', 'compat_tool': ''}})
try:
with tempfile.TemporaryDirectory(prefix='astroquest-install-') as temporary:
response = str(Path(temporary) / 'registered')
devkit_utils.execute_steam_client_command('create-shortcut?' + urlencode({
'response': response, 'gameid': gameid,
}))
with devkit_utils.wait_on_file_response(response, timeout=30):
pass
response = str(Path(temporary) / 'shortcuts')
devkit_utils.execute_steam_client_command('list-shortcuts?' + urlencode({'response': response}))
with devkit_utils.wait_on_file_response(response, timeout=30) as result:
shortcuts = json.loads(result)
if shortcuts.get('gameids', []).count(gameid) != 1:
fail('Steam did not confirm a single AstroQuest_Frame shortcut. Rerun install.sh with Steam running.')
except (devkit_utils.SteamResponse_Timeout, devkit_utils.SteamResponse_Error):
fail('App files are installed, but Steam registration failed. Rerun install.sh with Steam running.')
print(f'Installed in {app}')
print('Launch "Devkit Game: AstroQuest_Frame" from Library > Non-Steam on the Frame.')
print('Close the Steam dashboard to give the game focus. Relaunch after updating.')
if new_shortcut and game is None and not (home / 'Games/AstroQuest/CUSA12431/eboot.bin').is_file():
print('Before playing, copy your extracted game to ~/Games/AstroQuest/CUSA12431,')
print('or rerun install.sh with the extracted game folder as its argument.')
PY
+5 -1
View File
@@ -13,7 +13,11 @@ data="${XDG_DATA_HOME:-$HOME/.local/share}/astroquest"
mkdir -p "$data/user" "$data/logs"
cd "$data"
export DISPLAY=${DISPLAY:-:0}
export SHADPS4_TITLE_RESOLUTION=${SHADPS4_TITLE_RESOLUTION:-3}
if [[ ${SHADPS4_VR_FOVEATION:-0} == 1 ]]; then
export SHADPS4_TITLE_RESOLUTION=${SHADPS4_TITLE_RESOLUTION:-5}
else
export SHADPS4_TITLE_RESOLUTION=${SHADPS4_TITLE_RESOLUTION:-3}
fi
export SHADPS4_VR_FPS_CAP=${SHADPS4_VR_FPS_CAP:-60}
export SHADPS4_XR_WAIT=${SHADPS4_XR_WAIT:-30}
export SHADPS4_MAX_MSAA=${SHADPS4_MAX_MSAA:-1}
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Puts the Windows build where "Play Astro Bot VR.bat" runs it from: pc-vr/shadps4.exe, with a
# user folder of its own (settings fit for playing, not for testing; saves; logs).
# tools/make-pc-vr.sh copy the emulator from build/win-x64, make what is missing
# tools/make-pc-vr.sh --save also copy the test build's save, if pc-vr has none yet
set -eu
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
python=${PYTHON:-$(cat "$root/tools/python.local" 2>/dev/null || command -v python3 || command -v python)}
dest="$root/pc-vr"
mkdir -p "$dest/user/log"
cp "$root/build/win-x64/shadps4.exe" "$dest/shadps4.exe"
# The emulator's own settings: what the test build has, with a log that goes to a file in the
# background instead of holding the game up on a console.
"$python" - "$(cygpath -m "$root/build/win-x64/user/config.json")" \
"$(cygpath -m "$dest/user/config.json")" <<'EOF'
import json, os, sys
source, target = sys.argv[1], sys.argv[2]
config = json.load(open(target if os.path.exists(target) else source, encoding="utf-8"))
log = config.setdefault("Log", {})
log.update({"type": "file", "sync": False, "filter": "*:Info", "append": False,
"size_limit": 52428800})
config.setdefault("GPU", {}).update({"present_mode": "Mailbox", "full_screen": False})
config.setdefault("Input", {}).update({"motion_controls_enabled": True,
"background_controller_input": True})
config.setdefault("Vulkan", {}).update({"vkvalidation_enabled": False})
json.dump(config, open(target, "w", encoding="utf-8"), indent=2)
EOF
for name in users.json keys.json; do
[ -f "$dest/user/$name" ] || cp "$root/build/win-x64/user/$name" "$dest/user/$name"
done
mkdir -p "$dest/user/input_config"
for name in default.ini global.ini; do
[ -f "$dest/user/input_config/$name" ] ||
cp "$root/build/win-x64/user/input_config/$name" "$dest/user/input_config/$name"
done
if [ "${1:-}" = "--save" ] && [ ! -d "$dest/user/home/1000/savedata/CUSA12392" ]; then
mkdir -p "$dest/user/home/1000/savedata"
cp -r "$root/build/win-x64/user/home/1000/savedata/CUSA12392" "$dest/user/home/1000/savedata/"
echo "copied the test build's save"
fi
ls -la "$dest" "$dest/user"
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env bash
# Packs the files of a GitHub release into build/release/:
# AstroQuest-<version>-Quest3.apk the headset app, build/quest/astro-vr-host.apk
# AstroQuest-<version>-PC-VR-Windows.zip the PC play folder: the emulator from
# build/win-x64, the launcher, clean settings, and
# PkgTool (tools/pkgtool) for unpacking a game
# package; no game, saves, logs or caches
# SHA256SUMS.txt
# tools/make-release.sh <version> (the version must be the APK's versionName)
# tools/pkgtool is PkgTool-0.2.231.zip of https://github.com/maxton/LibOrbisPkg/releases/tag/v0.2
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
version=${1:?usage: tools/make-release.sh <version>}
python=${PYTHON:-$(cat "$root/tools/python.local" 2>/dev/null || command -v python3 || command -v python)}
aapt2="${ANDROID_SDK:-$LOCALAPPDATA/Android/Sdk}/build-tools/36.1.0/aapt2.exe"
name="AstroQuest-$version"
out="$root/build/release"
pc="$out/$name-PC-VR-Windows"
apk="$root/build/quest/astro-vr-host.apk"
apk_version=$("$aapt2" dump badging "$apk" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p")
[[ "$apk_version" == "$version" ]] || {
echo "build/quest/astro-vr-host.apk is version $apk_version, not $version" >&2; exit 1; }
rm -rf "$out"
# The first user's home: without it the Windows build asks at its first start whether to move
# saves over from where an older shadPS4 kept them, in a message box behind the game.
mkdir -p "$pc/pc-vr/user/input_config" "$pc/games" \
"$pc/pc-vr/user/home/1000/"{savedata,trophy,inputs}
cp "$apk" "$out/$name-Quest3.apk"
cp "$root/build/win-x64/shadps4.exe" "$pc/pc-vr/"
cp "$root/pc-vr/launch.ps1" "$pc/pc-vr/"
cp "$root/Play Astro Bot VR.bat" "$pc/"
cp "$root/pc-vr/user/input_config/default.ini" "$root/pc-vr/user/input_config/global.ini" \
"$pc/pc-vr/user/input_config/"
# What the launcher unpacks a game package with.
mkdir -p "$pc/pc-vr/pkgtool"
cp "$root/tools/pkgtool/PkgTool.exe" "$root/tools/pkgtool/LibOrbisPkg.dll" \
"$root/tools/pkgtool/LICENSE.txt" "$pc/pc-vr/pkgtool/"
cat > "$pc/pc-vr/pkgtool/README.txt" <<'EOF'
PkgTool and LibOrbisPkg 0.2.231, by Maxton, unchanged: the launcher uses them to unpack a game
package (.pkg). Licensed under the GNU Lesser General Public License, version 3 (LICENSE.txt).
Source: https://github.com/maxton/LibOrbisPkg (release v0.2).
EOF
# The settings file as documented: what the launcher's window saved here is left out.
sed '/^[a-z_]*=/d' "$root/pc-vr/settings.txt" > "$pc/pc-vr/settings.txt"
# The emulator's settings as the play folder has them, with everything that names this PC's
# folders or devices back at its default.
"$python" - "$(cygpath -m "$root/pc-vr/user/config.json")" \
"$(cygpath -m "$pc/pc-vr/user/config.json")" <<'EOF'
import json, sys
config = json.load(open(sys.argv[1], encoding="utf-8"))
config["General"].update({"addon_install_dir": "", "font_dir": "", "home_dir": "",
"install_dirs": [], "sys_modules_dir": "", "shadnet_server": ""})
config["Input"].update({"default_controller_id": "", "camera_id": -1,
"motion_controls_enabled": True, "background_controller_input": True})
config["Vulkan"].update({"gpu_id": -1, "vkvalidation_enabled": False, "renderdoc_enabled": False})
config["Log"].update({"type": "file", "sync": False, "filter": "*:Info", "append": False})
for key in [k for k in config["Audio"] if k.endswith("_device")]:
config["Audio"][key] = "Default Device"
json.dump(config, open(sys.argv[2], "w", encoding="utf-8"), indent=2)
EOF
cat > "$pc/games/PUT YOUR GAME HERE.txt" <<'EOF'
Put your own copy of ASTRO BOT Rescue Mission (European release CUSA12392, version 1.00 or
1.04) in this folder, then start "Play Astro Bot VR.bat". Either form will do, anywhere in
here:
- the game's folder, the one with eboot.bin in it, or
- the game's .pkg file: it is unpacked the first time, which takes a minute and about 13 GB.
(Only a package made from a dump of the game can be unpacked. One downloaded from the
PlayStation Store is encrypted and cannot be used. A package that is only the game's
update is not the game: see "The game's versions" in README-PC-VR.md on GitHub.)
If the game is somewhere else, just start: a window asks where it is.
EOF
cat > "$pc/README.txt" <<EOF
AstroQuest $version - ASTRO BOT Rescue Mission in VR, played on this PC and shown in a headset:
a Meta Quest through Virtual Desktop, or a PC headset through SteamVR or an OpenXR runtime of
its own. https://github.com/bigmak94/AstroQuest
1. Put your own copy of the game in the games folder: its folder (the one with eboot.bin in
it) or its .pkg file, which is unpacked the first time. Or skip this: a window asks where
the game is. Keep this folder's path short, e.g. C:\Games\AstroQuest: the emulator cannot
open the game's files whose full path would be longer than 260 characters.
2. Virtual Desktop: install the Streamer on this PC and choose VDXR as the OpenXR runtime in
its Options. In the headset, set Virtual Desktop's frame rate to 120 (Streaming settings).
(With SteamVR instead: start it and see that the headset is ready.)
3. Connect the DualSense to this PC (USB cable, or Bluetooth paired with the PC, not with the
headset). The headset's own controllers play too: without a gamepad, and with one
whenever they were used after it.
4. Connect Virtual Desktop to this PC, then start "Play Astro Bot VR.bat": its window says
what does what, and has the game's language to choose (Windows' own unless you say
otherwise). (If the Microsoft Visual C++ runtime is missing, it says so and offers
Microsoft's download.)
In the game: hold the controller where the outline is on the first screen; look at a planet
and press X to choose it; hold OPTIONS for a second to reset the view. With the headset's
controllers or a gamepad without a touchpad: right trigger presses the touchpad, right grip
(R1) swipes forward, left trigger pulls back and lets go (the catapult at the end of a level).
To turn round without turning yourself: hold L1 (the headset's controllers: the left grip)
and flick the right stick to a side.
Settings are in pc-vr\settings.txt, the log in pc-vr\user\log\shad_log.txt, saves in
pc-vr\user\home.
AstroQuest is free software under the GNU GPL, version 2 or later (LICENSE.txt); the source is
at the address above. It contains no part of the game: use it only with a game you own.
EOF
tr -d '\r' < "$root/LICENSE" > "$pc/LICENSE.txt"
cp "$root/THIRD-PARTY-NOTICES.md" "$pc/THIRD-PARTY-NOTICES.md"
"$python" - "$(cygpath -m "$out")" "$name-PC-VR-Windows" <<'EOF'
import os, sys, zipfile
out, folder = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(os.path.join(out, folder + ".zip"), "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as archive:
for here, folders, files in sorted(os.walk(os.path.join(out, folder))):
if not folders and not files:
archive.write(here, os.path.relpath(here, out).replace(os.sep, "/") + "/")
for name in sorted(files):
path = os.path.join(here, name)
archive.write(path, os.path.relpath(path, out).replace(os.sep, "/"))
EOF
(cd "$out" && sha256sum "$name-Quest3.apk" "$name-PC-VR-Windows.zip" > SHA256SUMS.txt)
ls -la "$out"
cat "$out/SHA256SUMS.txt"
-201
View File
@@ -1,201 +0,0 @@
// Assembles a Debian arm64 sysroot on a machine without WSL, a package manager or symlink
// privileges, for cross-compiling the emulator core with clang.
// node mk-sysroot.mjs <sysroot dir> [--suite trixie] [--cache dir] <package>...
// Packages are taken as listed (no dependency resolution). Symbolic links inside the packages
// become copies; /lib and friends become junctions onto /usr.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync, existsSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync,
} from "node:fs";
import { dirname, join, posix, resolve } from "node:path";
const args = process.argv.slice(2);
const sysroot = resolve(args.shift() ?? "");
let suite = "trixie";
let cache = join(dirname(sysroot), "deb-cache");
const wanted = [];
while (args.length) {
const arg = args.shift();
if (arg === "--suite") suite = args.shift();
else if (arg === "--cache") cache = args.shift();
else wanted.push(arg);
}
if (!sysroot || !wanted.length) {
console.error("usage: mk-sysroot.mjs <sysroot dir> [--suite trixie] [--cache dir] <package>...");
process.exit(1);
}
mkdirSync(cache, { recursive: true });
mkdirSync(sysroot, { recursive: true });
const mirror = "https://deb.debian.org/debian";
function decompress(tool, data) {
const result = spawnSync(tool, ["-dc"], { input: data, maxBuffer: 1 << 30 });
if (result.status !== 0) throw new Error(`${tool} failed: ${result.stderr}`);
return result.stdout;
}
async function download(url, file) {
if (existsSync(file) && statSync(file).size > 0) return readFileSync(file);
const response = await fetch(url);
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
const data = Buffer.from(await response.arrayBuffer());
writeFileSync(file, data);
return data;
}
// Later indexes win, so point-release and security updates replace the base version.
const indexes = [
[`${mirror}/dists/${suite}/main/binary-arm64/Packages.xz`, mirror],
[`${mirror}/dists/${suite}-updates/main/binary-arm64/Packages.xz`, mirror],
];
const packages = new Map();
for (const [url, base] of indexes) {
const file = join(cache, createHash("sha1").update(url).digest("hex").slice(0, 12) + "-Packages.xz");
let text;
try {
text = decompress("xz", await download(url, file)).toString("utf8");
} catch (error) {
console.warn(`skipping ${url}: ${error.message}`);
continue;
}
for (const stanza of text.split("\n\n")) {
const name = /^Package: (.+)$/m.exec(stanza)?.[1];
const filename = /^Filename: (.+)$/m.exec(stanza)?.[1];
const sha256 = /^SHA256: (.+)$/m.exec(stanza)?.[1];
const version = /^Version: (.+)$/m.exec(stanza)?.[1];
if (name && filename) packages.set(name, { url: `${base}/${filename}`, sha256, version });
}
}
function* arMembers(deb) {
if (deb.toString("latin1", 0, 8) !== "!<arch>\n") throw new Error("not an ar archive");
let pos = 8;
while (pos + 60 <= deb.length) {
const name = deb.toString("latin1", pos, pos + 16).trim().replace(/\/$/, "");
const size = Number(deb.toString("latin1", pos + 48, pos + 58).trim());
yield { name, data: deb.subarray(pos + 60, pos + 60 + size) };
pos += 60 + size + (size & 1);
}
}
function* tarEntries(tar) {
let pos = 0;
let longName = null, longLink = null;
let pax = {};
const text = (start, length) => {
const slice = tar.subarray(start, start + length);
const end = slice.indexOf(0);
return slice.toString("utf8", 0, end < 0 ? length : end);
};
while (pos + 512 <= tar.length) {
if (tar[pos] === 0) break;
let name = text(pos, 100);
const size = parseInt(text(pos + 124, 12).trim() || "0", 8);
const type = String.fromCharCode(tar[pos + 156] || 0x30);
let link = text(pos + 157, 100);
const prefix = text(pos + 345, 155);
if (prefix) name = `${prefix}/${name}`;
const data = tar.subarray(pos + 512, pos + 512 + size);
pos += 512 + Math.ceil(size / 512) * 512;
if (type === "L") { longName = data.toString("utf8").replace(/\0+$/, ""); continue; }
if (type === "K") { longLink = data.toString("utf8").replace(/\0+$/, ""); continue; }
if (type === "x" || type === "g") {
for (const record of data.toString("utf8").split("\n")) {
const match = /^\d+ ([^=]+)=(.*)$/.exec(record);
if (match) pax[match[1]] = match[2];
}
continue;
}
name = pax.path ?? longName ?? name;
link = pax.linkpath ?? longLink ?? link;
longName = longLink = null;
pax = {};
yield { name: name.replace(/^\.\//, "").replace(/\/$/, ""), type, link, data };
}
}
// Merged /usr: everything top-level that Debian symlinks into /usr is stored there directly.
const merged = ["lib", "bin", "sbin", "lib64"];
const canonical = (path) => {
const parts = path.split("/").filter(Boolean);
if (merged.includes(parts[0])) parts.unshift("usr");
return parts.join("/");
};
const links = [];
let fileCount = 0;
for (const name of wanted) {
const pkg = packages.get(name);
if (!pkg) throw new Error(`package ${name} not found in ${suite}`);
const debFile = join(cache, posix.basename(pkg.url));
const deb = await download(pkg.url, debFile);
if (pkg.sha256 && createHash("sha256").update(deb).digest("hex") !== pkg.sha256) {
throw new Error(`${name}: checksum mismatch`);
}
let tar = null;
for (const member of arMembers(deb)) {
if (member.name === "data.tar.xz") tar = decompress("xz", member.data);
else if (member.name === "data.tar.zst") tar = decompress("zstd", member.data);
else if (member.name === "data.tar.gz") tar = decompress("gzip", member.data);
else if (member.name === "data.tar") tar = member.data;
}
if (!tar) throw new Error(`${name}: no data archive`);
for (const entry of tarEntries(tar)) {
if (!entry.name) continue;
const path = canonical(entry.name);
const target = join(sysroot, path);
if (entry.type === "5") {
mkdirSync(target, { recursive: true });
} else if (entry.type === "0" || entry.type === "7") {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, entry.data);
fileCount++;
} else if (entry.type === "2" || entry.type === "1") {
// Hard links name their target from the archive root, symlinks from their own directory.
const resolved = entry.type === "1" || entry.link.startsWith("/")
? canonical(entry.link)
: canonical(posix.normalize(posix.join(posix.dirname(entry.name), entry.link)));
if (!path.startsWith("usr/share/doc/")) links.push({ path, resolved });
}
}
console.log(`${name} ${pkg.version}`);
}
// "include" is not a Debian link: clang finds GCC through /lib and then walks up with "..",
// which Windows resolves lexically instead of through the junction.
for (const dir of [...merged, "include"]) {
const junction = join(sysroot, dir);
if (!existsSync(junction) && existsSync(join(sysroot, "usr", dir))) {
symlinkSync(join(sysroot, "usr", dir), junction, "junction");
}
}
// Links may point at other links, so keep resolving until nothing changes.
let pending = links;
let linked = 0;
for (let pass = 0; pass < 8 && pending.length; pass++) {
const next = [];
for (const link of pending) {
const source = join(sysroot, link.resolved);
const target = join(sysroot, link.path);
if (!existsSync(source)) {
next.push(link);
continue;
}
if (statSync(source).isDirectory()) {
if (!existsSync(target)) {
mkdirSync(dirname(target), { recursive: true });
symlinkSync(source, target, "junction");
}
} else {
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
}
linked++;
}
pending = next;
}
console.log(`${fileCount} files, ${linked} links materialised, ${pending.length} dangling links skipped`);
for (const link of pending.slice(0, 40)) console.log(` dangling: ${link.path} -> ${link.resolved}`);
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Does the emulator survive Windows changing the device it plays on by default, the way a
# program that streams to a headset changes it when the headset connects? Plays the game
# (flat, no headset) with its sound meant for the device that is Windows' default now, makes
# another one the default after a while and the first one again after that, and says whether
# the emulator is still running and where its ports went.
# tools/pc-audio-default-test.sh "<other device>" [emulator exe] [seconds before the change]
# SETTING="Default Device" leaves the sound to the system instead of naming the first device.
# Both devices are audible meanwhile. Names: tools/audio-endpoint.ps1 list
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
other=${1:?the name of the other device}
exe=${2:-$root/build/win-x64/shadps4.exe}
warmup=${3:-35}
python=${PYTHON:-$(cat "$root/tools/python.local" 2>/dev/null || command -v python3 || command -v python)}
endpoint() {
powershell -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -w "$root/tools/audio-endpoint.ps1")" "$@" | tr -d '\r'
}
cd "$root/build/win-x64" || exit 1
first=$(endpoint default)
[ -n "$first" ] || { echo "Windows has no default playback device" >&2; exit 1; }
# The device Windows would play on if all were there: one that is not there now (a headset's,
# while nothing streams to it) has to be the preferred one again afterwards, or Windows no
# longer changes over to it when it comes back. (It did not, after this test's first runs.)
preferred=$(endpoint preferred)
config=user/config.json
cp "$config" "$config.audio-test"
restore() {
endpoint default "$first" > /dev/null
if [ -n "$preferred" ] && [ "$preferred" != "$first" ]; then
endpoint default "$preferred" > /dev/null
fi
mv "$root/build/win-x64/$config.audio-test" "$root/build/win-x64/$config"
}
trap restore EXIT
"$python" - "$config" "${SETTING:-$first}" <<'EOF'
import json, sys
config = json.load(open(sys.argv[1], encoding="utf-8"))
config["Audio"]["sdl_main_output_device"] = sys.argv[2]
json.dump(config, open(sys.argv[1], "w", encoding="utf-8"), indent=2)
EOF
export SHADPS4_VR_DEMO=1 SHADPS4_OPENXR=0
"$exe" -g "$(cygpath -w "$root/games/CUSA12392/eboot.bin")" > audio-default-test.log 2>&1 &
game=$!
running() { kill -0 "$game" 2>/dev/null && echo "the emulator is running" || echo "THE EMULATOR HAS STOPPED"; }
sleep "$warmup"
echo "$warmup s: $(running); Windows plays on: $first"
echo "now on: $(endpoint default "$other")"
sleep 14
echo "14 s later: $(running)"
echo "and back on: $(endpoint default "$first")"
sleep 14
echo "14 s later: $(running)"
kill "$game" 2>/dev/null
wait "$game" 2>/dev/null
grep -a -E "Opened audio device|sound moves|went away|now plays on|Audio output (added|removed)|Unhandled Exception|SDL_GetAudioStream" user/log/shad_log.txt |
sed -E 's/^\[[^]]*\] <[A-Za-z]+> \(([^)]*)\) [^ ]+ [A-Za-z_]+: /\1 | /' | cut -c1-200
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# Does the sound survive its device going away and coming back? Plays the game (flat, no
# headset) on a named playback device, takes that device out of Windows for a few seconds
# and puts it back, and measures the level played on it before and after.
# tools/pc-audio-device-test.sh "<device name>" [emulator exe] [seconds before the first measure]
# The device is audible meanwhile. Names: tools/audio-endpoint.ps1 list
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
device=${1:?device name}
exe=${2:-$root/build/win-x64/shadps4.exe}
warmup=${3:-60}
python=${PYTHON:-$(cat "$root/tools/python.local" 2>/dev/null || command -v python3 || command -v python)}
endpoint() {
powershell -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -w "$root/tools/audio-endpoint.ps1")" "$@" | tr -d '\r'
}
cd "$root/build/win-x64" || exit 1
config=user/config.json
cp "$config" "$config.audio-test"
restore() {
endpoint show "$device"
mv "$root/build/win-x64/$config.audio-test" "$root/build/win-x64/$config"
}
trap restore EXIT
"$python" - "$config" "${SETTING:-$device}" <<'EOF'
import json, sys
config = json.load(open(sys.argv[1], encoding="utf-8"))
config["Audio"]["sdl_main_output_device"] = sys.argv[2]
json.dump(config, open(sys.argv[1], "w", encoding="utf-8"), indent=2)
EOF
log=audio-device-test.log
SHADPS4_OPENXR=0 SHADPS4_VR_DEMO=1 timeout $((warmup + 85)) "$exe" \
-g "$(cygpath -w "$root/games/CUSA12392/eboot.bin")" > "$log" 2>&1 &
# The level on every device there is: the sound is to be on the named one, and on the
# system's default while that is gone.
levels() {
endpoint list | sed -n 's/^active *//p' | while IFS= read -r name; do
printf '%s %s; ' "$(endpoint peak "$name" 3)" "$name"
done
}
sleep "$warmup"
echo "before: $(levels)"
# CONTROL=1 leaves the device alone: what the game plays at those times anyway.
[ -n "${CONTROL:-}" ] || endpoint hide "$device"
sleep 10
echo "while it is gone: $(levels)"
endpoint show "$device"
sleep 10
echo "after it is back: $(levels)"
sleep 8
echo "and later: $(levels)"
wait
grep -a "Opened audio device\|went away\|is there: the sound\|not found, using default" "$log" |
sed 's/^.*sdl_audio_out.cpp:[0-9]* //' | uniq -c
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Runs the Windows build into the first level and keeps what the game has drawn there at full
# size, to compare what a setting does to the picture itself.
#
# tools/pc-dump-test.sh <name> <dump after seconds> [NAME=value ...]
#
# The render targets of the moment (the two eyes' pictures among them) end up as PNG files in
# build/dev/dump/<name>/, with the log.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
name=$1
after=$2
shift 2
user="$root/build/win-x64/user"
out="$root/build/dev/dump/$name"
script=${PC_DUMP_SCRIPT:-$root/build/dev/input-gametime.txt}
rm -rf "$out" "$user/rt_dump" "$user/dump_render_targets" "$user/screenshots"
mkdir -p "$out"
echo 1000000 > "$user/auto_shot_every"
# The flag the emulator looks for, raised when the game has got where it should be.
"$root/tools/after.sh" "$after" "$user/dump_render_targets" &
env SHADPS4_VR_DEMO=0 SHADPS4_DUMP_WIDTH=4096 SHADPS4_INPUT_SCRIPT="$script" "$@" \
bash "$root/tools/run-win.sh" $((after + 12)) > /dev/null
wait
cp "$user"/rt_dump/* "$out"/ 2>/dev/null
cp "$root/build/win-x64/run-stdout.log" "$out/log.txt"
echo "$name: $(ls "$out" | wc -l) files in $out"
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Plays the same scripted walk through the first level on the monitor at two frame rates and
# keeps a picture every two seconds by the clock from each, to see whether the game does the
# same things at the same moments when it draws faster than the console did.
#
# tools/pc-rate-compare.sh <name> "<NAME=value ...>" (settings of the run to compare)
#
# The reference run is the game's own way (60 frames a second). Pictures and logs end up in
# build/dev/rate/<name>/{reference,test}/, contact sheets next to them.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
name=$1
settings=${2:-}
user="$root/build/win-x64/user"
out="$root/build/dev/rate/$name"
seconds=${RATE_SECONDS:-332}
shots=${RATE_SHOTS:-2,264}
run() {
local label=$1
shift
rm -rf "$user/screenshots" "$out/$label"
mkdir -p "$out/$label"
echo 1000000 > "$user/auto_shot_every"
env SHADPS4_OPENXR=0 SHADPS4_VR_DEMO=0 SHADPS4_SHOT_SECONDS="$shots" SHADPS4_FRAME_STATS=1 \
SHADPS4_TITLE_RESOLUTION=6 SHADPS4_INPUT_SCRIPT="$root/build/dev/input-playcheck.txt" "$@" \
bash "$root/tools/run-win.sh" "$seconds" > /dev/null
n=0
for shot in "$user"/screenshots/*.png; do
[ -f "$shot" ] || continue
cp "$shot" "$out/$label/$(printf '%03d' $n).png"
n=$((n + 1))
done
cp "$root/build/win-x64/run-stdout.log" "$out/$label/log.txt"
node "$root/tools/contact.mjs" "$out/$label.png" --cols 8 --width 240 "$out/$label"/*.png | tail -1
}
run reference
# shellcheck disable=SC2086
run test $settings
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Runs the Windows build with a given resting head position and keeps the pictures it shows,
# to compare what the game draws for different positions of the head in tracker space.
#
# tools/pc-view-test.sh <name> <seconds> <"x,y,z" of the resting head, metres> [input script]
#
# The pictures (one every few seconds) end up in build/dev/view/<name>/, with the log.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
name=$1
seconds=$2
origin=$3
script=${4:-$root/build/dev/input-start.txt}
user="$root/build/win-x64/user"
out="$root/build/dev/view/$name"
rm -rf "$user/screenshots" "$out"
mkdir -p "$out"
printf '{ "origin_offset": [%s] }\n' "$origin" > "$user/vr.json"
echo ${SHOT_EVERY:-300} > "$user/auto_shot_every"
SHADPS4_VR_DEMO=0 SHADPS4_INPUT_SCRIPT="$script" bash "$root/tools/run-win.sh" "$seconds" > /dev/null
rm -f "$user/vr.json"
# In the order they were taken.
n=0
for shot in "$user"/screenshots/*.png; do
[ -f "$shot" ] || continue
cp "$shot" "$out/$(printf '%03d' $n).png"
n=$((n + 1))
done
cp "$root/build/win-x64/run-stdout.log" "$out/log.txt"
echo "$name: $n pictures in $out"
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Runs the Windows build with the Meta XR Simulator standing in for the headset: the emulator
# talks to it through OpenXR exactly as it talks to Virtual Desktop's runtime, without anybody
# wearing anything. The system's own OpenXR runtime is left alone (XR_RUNTIME_JSON only counts
# for the process started here).
#
# tools/pc-xrsim-test.sh <name> <seconds> [NAME=value ...]
#
# Settings (environment):
# XRSIM_HZ=<rate> refresh rate of the simulated display (72, 80, 90, 120)
# XRSIM_RECORD=<at>:<seconds> record what the simulator's compositor shows, from second <at>
# SHADPS4_INPUT_SCRIPT=<file> scripted controller input, as for tools/run-win.sh
#
# Log, the recording and a few pictures cut out of it end up in build/dev/xrsim/<name>/.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
name=$1
seconds=$2
shift 2
out="$root/build/dev/xrsim/$name"
simulator='C:\Program Files\MetaXRSimulator\v207.0\meta_openxr_simulator.json'
ffmpeg="/c/Program Files/Virtual Desktop Streamer/ffmpeg.exe"
rm -rf "$out"
mkdir -p "$out"
metavr xrsim app ensure-running > "$out/frontend.txt" 2>&1
env XR_RUNTIME_JSON="$simulator" "$@" bash "$root/tools/run-win.sh" "$seconds" > /dev/null &
game=$!
# The simulator's frontend learns of the game once its OpenXR session exists.
attached=0
for _ in $(seq 1 60); do
if metavr xrsim runtime list --format json --timeout 2 2>/dev/null | grep -q '"'; then
attached=1
break
fi
sleep 1
done
echo "runtime attached: $attached" >> "$out/frontend.txt"
if [ "$attached" = 1 ] && [ -n "${XRSIM_HZ:-}" ]; then
metavr xrsim device refresh-rate --rate "$XRSIM_HZ" --wait >> "$out/frontend.txt" 2>&1
fi
if [ "$attached" = 1 ] && [ -n "${XRSIM_RECORD:-}" ]; then
at=${XRSIM_RECORD%%:*}
length=${XRSIM_RECORD##*:}
sleep "$at"
metavr xrsim record --output "$(cygpath -w "$out/compositor.mp4")" --duration "$length" \
--overwrite >> "$out/frontend.txt" 2>&1
metavr xrsim runtime status >> "$out/frontend.txt" 2>&1
metavr xrsim runtime fps >> "$out/frontend.txt" 2>&1
fi
wait "$game"
cp "$root/build/win-x64/run-stdout.log" "$out/log.txt"
if [ -f "$out/compositor.mp4" ] && [ -x "$ffmpeg" ]; then
"$ffmpeg" -loglevel error -y -i "$(cygpath -w "$out/compositor.mp4")" -vf fps=1 \
"$(cygpath -w "$out")\\shot-%02d.png" 2>> "$out/frontend.txt"
fi
grep -a -c "" "$out/log.txt"
ls "$out"
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Where inside one function the samples of a thread fall, by source line.
Goes with tools/symbolize-stacks.mjs --profile: once that has named the function a thread
spends its time in, this tells which lines of it (inlined callees included).
profile-lines.py <core.log> <shadps4 binary> <thread name> <part of the function name>
"""
import collections
import os
import re
import subprocess
import sys
def main():
log, binary, thread, function = sys.argv[1:5]
innermost = collections.Counter()
for line in open(log, encoding='latin1'):
match = re.match(r'^ (.{16}) tid (\d+):(.*)$', line)
if not match or thread not in match.group(1):
continue
# The first frame inside the emulator itself; library frames come in brackets.
for frame in re.findall(r'\[[^\]]*\]|0x[0-9a-f]+', match.group(3)):
if frame.startswith('0x'):
innermost[frame] += 1
break
addresses = list(innermost)
tool = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'llvm', 'bin',
'llvm-addr2line.exe')
output = []
# A command line only holds so much.
for start in range(0, len(addresses), 400):
output += subprocess.run([tool, '-f', '-C', '-e', binary] + addresses[start:start + 400],
capture_output=True, text=True).stdout.split('\n')[:800]
lines = collections.Counter()
for index, address in enumerate(addresses):
name, place = output[2 * index], output[2 * index + 1]
if function in name:
place = place.replace(chr(92), '/').split('/src/')[-1]
lines[place] += innermost[address]
total = sum(innermost.values())
print(f'{function}: {sum(lines.values())} of {total} samples')
for place, count in lines.most_common(25):
print(f'{count:5} {place}')
if __name__ == '__main__':
main()
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
# Sums up a run of tools/quest-level-bench.sh: tools/quest-bench-read.sh <name> [all]
# Frames a second by ten seconds, for how many refreshes frames were shown, what the governor
# decided, and every ten seconds what frames took, at what size, with the GPU how busy.
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
out="$root/build/quest/bench"
name=$1
echo "fps by 10 s: $(grep -a -E "^[0-9]+ s:" "$out/$name.txt" | sed -E 's/.*frames, ([0-9.]+) per second.*/\1/' | tr '\n' ' ')"
echo "shown for 1-2-3-more refreshes: $(grep -a -E "shown for" "$out/$name.txt" | sed -E 's/ *frames shown for one refresh: ([0-9]+), for two: ([0-9]+), for three: ([0-9]+), for more: ([0-9]+)/\1-\2-\3-\4/' | tr '\n' ' ')"
echo "GPU clock by 10 s (busy% MHz C): $(awk 'NR%10==1 {printf "%s/%s/%s ", $2, $3, $4}' "$out/$name.gpu.txt")"
pattern="scene is drawn at|Frames are given|title's clock"
[ "${2:-}" = all ] && pattern="$pattern|frame stats at|frame path"
sed -e 's/\x1b\[[0-9;]*m//g' "$out/$name.core.log" | grep -a -E "$pattern" \
| sed -E 's/^\[[A-Za-z.]+\] <Info> \([A-Za-z:]+\) [a-z_]+\.cpp:[0-9]+ [A-Za-z]+: //' \
| sed -E 's/, per frame [0-9.]+ passes \([^)]*\)//; s/, [0-9.]+ dispatches.*//; s/The title.s clock: //; s/, the game ran at ([0-9]+)% of its speed over the last [0-9]+ s \(it would have at [0-9]+% left to itself\)/ (\1% speed)/' \
| cut -c1-240
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Runs the self-test several times in a row with different settings and keeps each run's
# report and logs under build/quest/bench/<name>.*, for comparing what a setting costs.
#
# tools/quest-bench-series.sh <seconds> <common settings> -- <name>:<settings> [<name>:<settings> ...]
#
# Settings are what tools/quest-selftest.sh takes (NAME=value, separated by spaces; quote
# each group). Example:
# tools/quest-bench-series.sh 185 "HOST_LOOK=8:85,22;115:0,-12 HOST_COMPOSITOR=90" -- \
# "plain:SHADPS4_MAX_MSAA=1" "msaa2:SHADPS4_MAX_MSAA=2"
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
seconds=$1
common=$2
shift 3
out="$root/build/quest/bench"
mkdir -p "$out"
for run in "$@"; do
name=${run%%:*}
settings=${run#*:}
rm -f "$out/$name.done"
# shellcheck disable=SC2086
bash "$root/tools/quest-selftest.sh" "$seconds" $common $settings > "$out/$name.txt" 2>&1
cp "$root/build/quest/selftest/core.log" "$out/$name.core.log" 2>/dev/null
cp "$root/build/quest/selftest/host.log" "$out/$name.host.log" 2>/dev/null
echo done > "$out/$name.done"
done
echo done > "$out/series.done"
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bash
# Runs the ARM64 core on a connected Quest straight from adb shell, without the VR host app:
# no display, scripted controller input, a screenshot every N presented frames. This checks the
# core on the real CPU and GPU even while the headset is locked or not being worn.
#
# tools/quest-core-test.sh <seconds> [input script] [screenshot interval] [NAME=value ...]
#
# Needs the runtime in /data/local/tmp/astro/runtime and the game in
# /data/local/tmp/astro/games/CUSA12392 on the device. Results land in build/quest/run/.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
# The headset also shows up over the network: name the USB connection.
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
device=/data/local/tmp/astro
# ASTRO_DRIVER picks another Vulkan driver folder on the device (default: the packaged Turnip).
driver=${ASTRO_DRIVER:-$device/runtime/drivers/turnip}
seconds=${1:-60}
script=${2:-}
shots=${3:-600}
shift $(( $# < 3 ? $# : 3 ))
extra_env="$*"
cd "$root" || exit 1
mkdir -p build/quest/run
tools/llvm/bin/llvm-strip.exe -o build/quest/shadps4-stripped build/arm64/shadps4 || exit 1
"$ADB" push build/quest/shadps4-stripped $device/runtime/host/shadps4-arm64-fex | tail -1
user=$device/home/data/shadPS4
"$ADB" shell "mkdir -p $user $device/home/cache $device/tmp && chmod 755 $device/runtime/host/shadps4-arm64-fex \
&& rm -rf $user/screenshots && echo $shots > $user/auto_shot_every && rm -f $device/core.log $device/input.txt"
input_env=""
if [[ -n "$script" ]]; then
"$ADB" push "$script" $device/input.txt | tail -1
input_env="SHADPS4_INPUT_SCRIPT=$device/input.txt"
fi
"$ADB" shell "cd $device && env HOME=$device/home XDG_DATA_HOME=$device/home/data XDG_CACHE_HOME=$device/home/cache \
XDG_CONFIG_HOME=$device/home/config TMPDIR=$device/tmp MESA_SHADER_CACHE_DIR=$device/home/cache/mesa \
GLIBC_TUNABLES=glibc.pthread.rseq=0 LD_LIBRARY_PATH=$device/runtime/host:$driver \
VK_ICD_FILENAMES=$driver/freedreno_icd.aarch64.json \
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SHADPS4_HEADLESS=1 $input_env $extra_env \
timeout $seconds ./runtime/host/ld-linux-aarch64.so.1 --library-path $device/runtime/host:$driver \
./runtime/host/shadps4-arm64-fex -g $device/games/CUSA12392/eboot.bin > core.log 2>&1; echo exit=\$? >> core.log"
rm -rf build/quest/run/screenshots
"$ADB" pull $device/core.log build/quest/run/core.log | tail -1
"$ADB" pull $user/screenshots build/quest/run/ 2>&1 | tail -1
"$ADB" pull $user/log build/quest/run/ 2>&1 | tail -1
tail -1 build/quest/run/core.log
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
# Starts the installed app's own activity on a connected Quest without showing it, which needs
# nobody to wear or unlock the headset: the activity is created and runs everything it runs
# when the app is launched (settings, log files, the OpenXR session up to "idle", the runtime,
# the emulator with the game, sound, the status panel's text), for the given seconds, and is
# then destroyed. What is not reached is what needs a display: the running session, the
# permission question, the controller.
#
# tools/quest-drystart.sh [seconds, default 60] [settings file]
#
# A settings file (lines as in vrhost.txt) is used in place of the one on the headset.
#
# The app's host.log and core.log land in build/quest/drystart/.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
# (ASTRO_PACKAGE: the build made by quest-host/build.sh --test-package, if that is the
# one to run.)
package=${ASTRO_PACKAGE:-com.astrobotquest.vrhost}
seconds=${1:-60}
out="$root/build/quest/drystart"
files=/sdcard/Android/data/$package/files
settings=""
if [ -n "${2:-}" ]; then
"$ADB" push "$2" /data/local/tmp/astro/vrhost-test.txt > /dev/null
"$ADB" shell "chmod 644 /data/local/tmp/astro/vrhost-test.txt"
settings="-e settings /data/local/tmp/astro/vrhost-test.txt"
fi
"$ADB" shell "am instrument -w -e drystart $seconds $settings $package/com.astrobotquest.vrhost.SandboxShell" > /dev/null
mkdir -p "$out"
for name in host.log core.log; do
# (Not "adb pull": with path conversion off, adb.exe is given a path it cannot write to.)
"$ADB" exec-out "cat $files/$name" > "$out/$name" 2>/dev/null
[ -s "$out/$name" ] || echo "no $name"
done
# The pictures of the status panel, as the activity drew them.
for number in 0 1 2 3 4 5 6 7 8 9 10 11; do
"$ADB" exec-out "run-as $package cat files/drystart/status-$number.png" \
> "$out/status-$number.png" 2>/dev/null
[ -s "$out/status-$number.png" ] || rm -f "$out/status-$number.png"
done
grep -v "starting core" "$out/host.log" | cut -c1-220
echo "core.log: $(grep -a -c "<Critical>" "$out/core.log") critical lines, last frame stats:"
grep -a "frame stats" "$out/core.log" | tail -1 | sed 's/\x1b\[[0-9;]*m//g; s/.*EndFrame: //' | cut -c1-200
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Writes how busy the headset's GPU is and how fast it is clocked, once a second, for so many
# seconds: tools/quest-gpu-watch.sh <seconds> <file>. One line per sample:
# seconds busy% GPU MHz GPU temperature
# The shell may read that while the headset sleeps; the app may not.
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)} MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
"$ADB" shell "start=\$(date +%s); while [ \$(( \$(date +%s) - start )) -lt $1 ]; do
echo \"\$(( \$(date +%s) - start )) \$(cat /sys/class/kgsl/kgsl-3d0/gpu_busy_percentage | tr -d ' %') \$(( \$(cat /sys/class/kgsl/kgsl-3d0/gpuclk) / 1000000 )) \$(( \$(cat /sys/class/kgsl/kgsl-3d0/temp) / 1000 ))\"
sleep 1; done" > "$2" 2>&1
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Runs the app's self-test into the first level of the game (it starts from a save, past the
# prologue: the test's own data folder bench-data in the app's files folder, made once from the
# sandbox tests' save) with a stand-in for the compositor and a display loop, and keeps the
# report, the logs and the GPU's clock under build/quest/bench/<name>.*:
#
# tools/quest-level-bench.sh <name> <seconds> [NAME=value ...]
#
# BENCH_HZ=<rate> in the environment sets the refresh rate of the stand-in display, of the
# stand-in compositor and of the emulated headset (90 unless told). They are not to be given
# among the settings as well: the self-test does not take a setting of its own twice.
#
# The head looks at the level's first planet until the level is in (85 degrees left, 22 up),
# then down the level from second 110 on, which is its heaviest view. Prints a summary.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
name=$1
seconds=$2
shift 2
out="$root/build/quest/bench"
data=/data/user/0/com.astrobotquest.vrhost/files/bench-data
hz=${BENCH_HZ:-90}
mkdir -p "$out"
rm -f "$out/$name.done"
bash "$root/tools/quest-gpu-watch.sh" $((seconds + 15)) "$out/$name.gpu.txt" &
bash "$root/tools/quest-selftest.sh" "$seconds" "HOST_LOOK=8:85,22;110:0,-12" "HOST_DISPLAY=$hz" \
"HOST_COMPOSITOR=$hz" "SHADPS4_VR_REFRESH_RATE=$hz" "XDG_DATA_HOME=$data" "$@" \
> "$out/$name.txt" 2>&1
cp "$root/build/quest/selftest/core.log" "$out/$name.core.log" 2>/dev/null
cp "$root/build/quest/selftest/host.log" "$out/$name.host.log" 2>/dev/null
wait
echo done > "$out/$name.done"
bash "$root/tools/quest-bench-read.sh" "$name"
-24
View File
@@ -1,24 +0,0 @@
# Runs on the headset, next to the emulator core (tools/quest-sandbox-test.sh starts it): one
# line every <interval> seconds with the core's resident and swapped memory, what the GPU driver
# holds for it, and what the system has left. All sizes in MB.
# sh memwatch.sh <interval seconds>
interval=${1:-10}
start=$(date +%s)
while sleep "$interval"; do
pid=$(pidof libastro_ld.so)
[ -z "$pid" ] && continue
status=/proc/$pid/status
rss=$(grep VmRSS: $status | tr -dc 0-9)
anon=$(grep RssAnon: $status | tr -dc 0-9)
file=$(grep RssFile: $status | tr -dc 0-9)
shmem=$(grep RssShmem: $status | tr -dc 0-9)
swap=$(grep VmSwap: $status | tr -dc 0-9)
available=$(grep MemAvailable: /proc/meminfo | tr -dc 0-9)
swapfree=$(grep SwapFree: /proc/meminfo | tr -dc 0-9)
gpu=$(cat /sys/class/kgsl/kgsl/proc/$pid/gpumem_mapped 2>/dev/null)
gpu_unmapped=$(cat /sys/class/kgsl/kgsl/proc/$pid/gpumem_unmapped 2>/dev/null)
echo "t=$(( $(date +%s) - start )) rss=$((rss / 1024)) anon=$((anon / 1024)) file=$((file / 1024))" \
"shmem=$((shmem / 1024)) swap=$((swap / 1024)) gpu=$(( ${gpu:-0} / 1048576 ))" \
"gpu_unmapped=$(( ${gpu_unmapped:-0} / 1048576 )) available=$((available / 1024))" \
"swapfree=$((swapfree / 1024))"
done
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
# Builds one of the small C probes in tools/probes for the emulator's Linux runtime and runs it
# on a connected Quest inside the host app's sandbox, with the same loader, libraries, GPU driver
# and driver shim the emulator core gets there.
#
# tools/quest-probe.sh <probe.c> [arguments...]
#
# ASTRO_DRIVER folder of the Vulkan driver inside the app's files folder
# (default runtime/drivers/turnip)
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
# The headset also shows up over the network: name the USB connection.
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
# (ASTRO_PACKAGE: the build made by quest-host/build.sh --test-package, if that is the
# one to run.)
package=${ASTRO_PACKAGE:-com.astrobotquest.vrhost}
stage=/data/local/tmp/astro
driver=${ASTRO_DRIVER:-runtime/drivers/turnip}
source=${1:?usage: quest-probe.sh <probe.c> [arguments...]}
shift
name=$(basename "$source" .c)
cd "$root" || exit 1
mkdir -p build/quest/probes
tools/llvm/bin/aarch64-linux-gnu-clang.exe -O1 -Wall \
-I shadps4-arm64-main/externals/vulkan-headers/include \
"$source" -o "build/quest/probes/$name" \
build/quest/runtime/host/libvulkan.so.1 -lpthread || exit 1
"$ADB" push "build/quest/probes/$name" "$stage/$name" > /dev/null || exit 1
"$ADB" shell "chmod 644 $stage/$name"
# The loader is not the first word after env: the app folder has "=" in its name, which env
# would take for a variable assignment.
command="
cp $stage/$name ./$name && chmod 700 ./$name
env HOME=\$PWD/home TMPDIR=\$PWD/tmp GLIBC_TUNABLES=glibc.pthread.rseq=0 \
LD_LIBRARY_PATH=\$PWD/runtime/host:\$PWD/$driver \
VK_ICD_FILENAMES=\$PWD/$driver/freedreno_icd.aarch64.json \
timeout 120 \$ASTRO_LIB_DIR/libastro_ld.so --library-path \$PWD/runtime/host:\$PWD/$driver \
--preload \$PWD/runtime/host/libkgsl_compat.so ./$name $* 2>&1
"
"$ADB" shell "am instrument -w -e cmd '$command' $package/com.astrobotquest.vrhost.SandboxShell" \
| sed -e 's/^INSTRUMENTATION_RESULT: stream=//' -e '/^INSTRUMENTATION_CODE/d'
-51
View File
@@ -1,51 +0,0 @@
#!/system/bin/sh
# Runs on the headset itself (tools/quest-recorder.sh puts it there and starts it) and stays
# until the headset restarts. Whenever the emulator runs, with or without a USB cable, it keeps
# what only a shell on the headset may see, in /data/local/tmp/astro/sessions/<start time>/:
#
# gpu.txt every 2 s: seconds, GPU load %, GPU clock MHz, GPU temperature, the clock of
# every processor core in MHz
# system.txt the VR runtime's own lines as it writes them: once a second the frame rate it
# sees, stale frames, processor and GPU level and clock, GPU time of the app and
# of the compositor ("VrApi"), and every change of clock level with its reason
# ("crcs")
# threads.txt every 30 s: the emulator's busiest threads and the cores they ran on
#
# The ten newest sessions are kept.
root=/data/local/tmp/astro
sessions=$root/sessions
mkdir -p $sessions
echo $$ > $root/recorder.pid
# Makes the system say why it picks the clock levels it picks (until the headset restarts).
setprop debug.oculus.clockStateLogLevel 1 2>/dev/null
while true; do
until [ -n "$(pidof libastro_ld.so)" ]; do
sleep 3
done
core=$(pidof libastro_ld.so)
out=$sessions/$(date +%Y%m%d-%H%M%S)
mkdir -p $out
logcat -T 1 -v time VrApi:V crcs:V '*:S' > $out/system.txt 2>&1 &
logcat_pid=$!
start=$(date +%s)
samples=0
while [ -d /proc/$core ]; do
echo "$(( $(date +%s) - start )) $(cat /sys/class/kgsl/kgsl-3d0/gpu_busy_percentage | tr -d ' %') $(( $(cat /sys/class/kgsl/kgsl-3d0/gpuclk) / 1000000 )) $(( $(cat /sys/class/kgsl/kgsl-3d0/temp) / 1000 )) $(cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq | while read f; do printf '%s ' $(( f / 1000 )); done)" >> $out/gpu.txt
if [ $(( samples % 15 )) = 0 ]; then
{
echo "== $(( $(date +%s) - start )) s"
top -H -b -n 1 -m 24 -o PID,TID,%CPU,S,PR,NI,CMD,PCY,CPU -p $core 2>&1 | tail -n +6
echo "cpus allowed: $(grep Cpus_allowed_list /proc/$core/status | cut -f2), cpuset $(cat /proc/$core/cpuset)"
} >> $out/threads.txt
fi
samples=$(( samples + 1 ))
sleep 2
done
kill $logcat_pid 2>/dev/null
# Oldest first; all but the ten newest go.
count=$(ls $sessions | wc -l)
for old in $(ls $sessions | head -n $(( count > 10 ? count - 10 : 0 ))); do
rm -rf $sessions/$old
done
done
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
# The headset is played without a cable, so what a session looked like to the system has to be
# written down on the headset itself. This keeps a small recorder running there (see
# tools/quest-recorder-device.sh for what it keeps) and fetches what it and the app recorded.
#
# tools/quest-recorder.sh start put the recorder on the headset and start it (it stays
# until the headset restarts; starting it twice is harmless)
# tools/quest-recorder.sh status whether it runs, and the sessions it has
# tools/quest-recorder.sh pull fetch everything to build/quest/sessions/<start time>/,
# with the app's host.log and core.log next to the newest
# tools/quest-recorder.sh stop
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
stage=/data/local/tmp/astro
package=com.astrobotquest.vrhost
running() {
"$ADB" shell "pid=\$(cat $stage/recorder.pid 2>/dev/null); [ -n \"\$pid\" ] && grep -q recorder /proc/\$pid/cmdline 2>/dev/null && echo \$pid" | tr -d '\r'
}
case "${1:-status}" in
start)
if [ -n "$(running)" ]; then
echo "the recorder already runs (process $(running))"
exit 0
fi
# (A relative path: with path conversion off, adb.exe does not know what /c/... is.)
cd "$root" || exit 1
"$ADB" push tools/quest-recorder-device.sh $stage/recorder.sh > /dev/null || exit 1
# Its own session and no terminal: it has to outlive this connection and the cable.
"$ADB" shell "setsid sh $stage/recorder.sh < /dev/null > /dev/null 2>&1 &"
sleep 2
if [ -n "$(running)" ]; then
echo "the recorder runs (process $(running))"
else
echo "the recorder did not start"
exit 1
fi
;;
stop)
pid=$(running)
if [ -n "$pid" ]; then
# Its children (the log reader of a session under way) go with it.
"$ADB" shell "pkill -P $pid; kill $pid"
echo "stopped"
else
echo "it was not running"
fi
;;
status)
pid=$(running)
echo "recorder: ${pid:+runs as process }${pid:-not running}"
"$ADB" shell "cd $stage/sessions 2>/dev/null && for s in *; do [ -d \$s ] && echo \"\$s: \$(wc -l < \$s/gpu.txt 2>/dev/null) samples, \$(grep -c FPS= \$s/system.txt 2>/dev/null) runtime lines\"; done; true"
;;
pull)
out="$root/build/quest/sessions"
mkdir -p "$out"
names=$("$ADB" shell "ls $stage/sessions 2>/dev/null" | tr -d '\r')
newest=""
for name in $names; do
mkdir -p "$out/$name"
for file in gpu.txt system.txt threads.txt; do
# (Not "adb pull": with path conversion off, adb.exe is given a path it cannot write to.)
"$ADB" exec-out "cat $stage/sessions/$name/$file 2>/dev/null" > "$out/$name/$file"
done
newest=$name
done
# The app's own logs are of its last two starts.
files=/sdcard/Android/data/$package/files
target="$out/${newest:-app}"
mkdir -p "$target"
for file in host.log core.log host.prev.log core.prev.log; do
"$ADB" exec-out "cat $files/$file 2>/dev/null" > "$target/$file"
done
echo "sessions: ${names:-none}"
ls -la "$target"
;;
*)
echo "usage: $0 start|status|pull|stop"
exit 64
;;
esac
-172
View File
@@ -1,172 +0,0 @@
#!/usr/bin/env bash
# Runs the ARM64 core on a connected Quest inside the host app's sandbox, without the headset
# having to be worn or unlocked: no display, scripted controller input, a screenshot every N
# presented frames. The app's SandboxShell instrumentation starts the process, so it runs under
# the same security domain and system-call filter as when the app itself launches it (adb shell
# and run-as do not: neither may allocate GPU memory on Quest).
#
# tools/quest-sandbox-test.sh <seconds> [input script] [screenshot interval] [NAME=value ...]
#
# The core gets the settings the app gives it by default (no multisampling: SHADPS4_MAX_MSAA=1,
# the driver's direct rendering: TU_DEBUG=sysmem, a headset of 90 Hz: SHADPS4_VR_REFRESH_RATE=90,
# which has the game draw 45 frames a second, or 30 where that is all it manages, and the
# picture sharpened: SHADPS4_VR_SHARPEN=0.6); a NAME=value given here replaces them.
# The sandbox is not a VR session: the process may only use processors 0-2 there (a VR app gets
# three others), and the GPU's clock follows the load up to 690 MHz (a VR session holds it at
# 599 at most).
#
# Needs the app installed (quest-host/build.sh), the runtime staged in build/quest/runtime and
# the game in /data/local/tmp/astro/games/CUSA12392. Results land in build/quest/run/.
#
# Settings of the script itself come from the environment:
# ASTRO_DRIVER folder of the Vulkan driver inside the app's files folder
# (default runtime/drivers/turnip; drivers/<name> holds the other bundled builds)
# ASTRO_DUMP_AT seconds after which the core dumps its render targets (user/rt_dump)
# ASTRO_TOP_AT seconds after which the busiest threads of the core are listed (run/top.txt)
# ASTRO_MEM_EVERY seconds between samples of the core's memory use (run/mem.txt)
# ASTRO_THREADS_AT seconds after which the core logs what each guest thread is doing and
# samples the native stack of every thread (ASTRO_THREAD_SAMPLES times, 12 by
# default); tools/symbolize-stacks.sh turns the addresses into functions
# ASTRO_AT "<seconds>:<command>;<seconds>:<command>..." runs shell commands in the
# app's files folder at those times (no single quotes in them). With
# TU_DEBUG_FILE=$PWD/tu_debug.txt given as a setting of the core, that is how the
# driver's rendering mode is switched while the game runs:
# ASTRO_AT="300:printf sysmem > tu_debug.txt;340:printf gmem > tu_debug.txt"
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
# The headset also shows up over the network once wireless debugging has been used: name the
# USB connection so that adb does not have to guess.
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
# (ASTRO_PACKAGE: the build made by quest-host/build.sh --test-package, if that is the
# one to run.)
package=${ASTRO_PACKAGE:-com.astrobotquest.vrhost}
stage=/data/local/tmp/astro
game=${ASTRO_GAME:-$stage/games/CUSA12392/eboot.bin}
driver=${ASTRO_DRIVER:-runtime/drivers/turnip}
dump_at=${ASTRO_DUMP_AT:-}
dump_command=""
top_at=${ASTRO_TOP_AT:-}
top_command=""
if [[ -n "$top_at" ]]; then
top_command="(sleep $top_at; top -H -b -n 3 -d 4 -m 45 -p \$(pidof libastro_ld.so) > top.txt 2>&1) &"
fi
threads_at=${ASTRO_THREADS_AT:-}
threads_command=""
if [[ -n "$threads_at" ]]; then
threads_command="(sleep $threads_at; echo ${ASTRO_THREAD_SAMPLES:-12} > user/dump_threads) &"
fi
mem_every=${ASTRO_MEM_EVERY:-}
mem_command=""
if [[ -n "$mem_every" ]]; then
mem_command="sh $stage/memwatch.sh $mem_every > mem.txt 2>&1 &"
fi
if [[ -n "$dump_at" ]]; then
dump_command="(sleep $dump_at; touch user/dump_render_targets) &"
fi
# ASTRO_GPUTRACE_AT=<seconds>: the driver's own trace of what the GPU does (every render pass
# with the time the GPU reached it) from that moment on, ASTRO_GPUTRACE_BYTES of it (12 MB, a
# few seconds). The driver writes the trace from start to end; a pipe lets all but the wanted
# part go to waste. tools/gputrace.py sums the result (run/gputrace.txt) up.
trace_at=${ASTRO_GPUTRACE_AT:-}
trace_command=""
trace_env=""
if [[ -n "$trace_at" ]]; then
trace_command="rm -f gputrace.fifo gputrace.txt; mkfifo gputrace.fifo
(timeout $trace_at cat > /dev/null; head -c ${ASTRO_GPUTRACE_BYTES:-12000000} > gputrace.txt; cat > /dev/null) < gputrace.fifo &"
trace_env="MESA_GPU_TRACES=print MESA_GPU_TRACEFILE=\$PWD/gputrace.fifo"
fi
at_commands=""
if [[ -n "${ASTRO_AT:-}" ]]; then
IFS=';' read -ra at_entries <<< "$ASTRO_AT"
for entry in "${at_entries[@]}"; do
at_commands+="(sleep ${entry%%:*}; ${entry#*:}) &
"
done
fi
seconds=${1:-60}
script=${2:-}
shots=${3:-600}
shift $(( $# < 3 ? $# : 3 ))
extra_env="$*"
cd "$root" || exit 1
mkdir -p build/quest/run
tools/llvm/bin/llvm-strip.exe -o build/quest/shadps4-stripped build/arm64/shadps4 || exit 1
tools/llvm/bin/aarch64-linux-gnu-clang.exe -O2 -fPIC -shared quest-host/runtime/kgsl_compat.c \
-o build/quest/libkgsl_compat.so -lpthread || exit 1
# Stage through /data/local/tmp, which adb can write and the app can read.
"$ADB" push build/quest/shadps4-stripped $stage/shadps4-arm64-fex | tail -1
"$ADB" push build/quest/libkgsl_compat.so $stage/libkgsl_compat.so | tail -1
input_env=""
if [[ -n "$script" ]]; then
"$ADB" push "$script" $stage/input.txt | tail -1
input_env="SHADPS4_INPUT_SCRIPT=\$PWD/input.txt"
fi
"$ADB" push tools/quest-memwatch.sh $stage/memwatch.sh > /dev/null
"$ADB" shell "chmod 644 $stage/shadps4-arm64-fex $stage/libkgsl_compat.so $stage/input.txt $stage/memwatch.sh 2>/dev/null"
# Everything below runs inside the sandbox, in the app's files folder.
command="
mkdir -p runtime/host user home/cache tmp
rm -f runtime/.stamp
[ -d runtime/drivers ] || cp -r $stage/runtime/. runtime/
cp $stage/shadps4-arm64-fex runtime/host/shadps4-arm64-fex
cp $stage/libkgsl_compat.so runtime/host/libkgsl_compat.so
[ -f $stage/input.txt ] && cp $stage/input.txt input.txt
rm -rf user/screenshots user/rt_dump user/dump_render_targets user/*.wav core.log top.txt mem.txt
echo $shots > user/auto_shot_every
: > tu_debug.txt
$dump_command
$at_commands
$trace_command
$top_command
$threads_command
$mem_command
env HOME=\$PWD/home XDG_DATA_HOME=\$PWD/home/data XDG_CACHE_HOME=\$PWD/home/cache \
XDG_CONFIG_HOME=\$PWD/home/config TMPDIR=\$PWD/tmp MESA_SHADER_CACHE_DIR=\$PWD/home/cache/mesa \
GLIBC_TUNABLES=glibc.pthread.rseq=0 \
LD_LIBRARY_PATH=\$PWD/runtime/host:\$PWD/$driver \
VK_ICD_FILENAMES=\$PWD/$driver/freedreno_icd.aarch64.json \
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SHADPS4_HEADLESS=1 SHADPS4_MAX_MSAA=1 \
TU_DEBUG=sysmem KGSL_COMPAT_PRIORITY=12 SHADPS4_VR_REFRESH_RATE=90 SHADPS4_VR_SHARPEN=0.6 \
SHADPS4_THREAD_NICE=Game:Main=-14,shadPS4:GpuComm=-14,sndx_out_thread=-16,sndx_audio3d=-16,shadPS4:AudioOu=-16,shadPS4:VrHostL=-18,shadPS4:Present=-18 \
$input_env $trace_env $extra_env \
timeout $seconds \$ASTRO_LIB_DIR/libastro_ld.so \
--library-path \$PWD/runtime/host:\$PWD/$driver \
--preload \$PWD/runtime/host/libkgsl_compat.so \
runtime/host/shadps4-arm64-fex -g $game > core.log 2>&1
echo exit=\$? >> core.log
tail -c 2000 core.log
"
# How busy the GPU is and how fast it and the processors are clocked, every five seconds: the
# shell may read that, the app may not. One line per sample in run/gpu.txt:
# seconds busy% GPU MHz GPU temperature MHz of each processor
"$ADB" shell 'start=$(date +%s); while true; do
echo "$(( $(date +%s) - start )) $(cat /sys/class/kgsl/kgsl-3d0/gpu_busy_percentage | tr -d " %") $(( $(cat /sys/class/kgsl/kgsl-3d0/gpuclk) / 1000000 )) $(( $(cat /sys/class/kgsl/kgsl-3d0/temp) / 1000 )) $(cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq | while read f; do printf "%s " $(( f / 1000 )); done)"
sleep 5; done' > build/quest/run/gpu.txt 2>&1 &
sampler=$!
"$ADB" shell "am instrument -w -e cmd '$command' $package/com.astrobotquest.vrhost.SandboxShell" > build/quest/run/instrument.txt 2>&1
kill $sampler 2>/dev/null
rm -rf build/quest/run/screenshots build/quest/run/log build/quest/run/rt_dump
# A core stuck in a fault loop writes gigabytes; the beginning and the end tell the story.
# (Stack samples are long and sit in the middle: with them, up to 60 MB are fetched.)
if [[ -n "$threads_at" ]]; then
"$ADB" exec-out "run-as $package sh -c 'head -c 60000000 files/core.log'" > build/quest/run/core.log
else
"$ADB" exec-out "run-as $package sh -c 'head -c 6000000 files/core.log; echo; echo ...; tail -c 400000 files/core.log'" > build/quest/run/core.log
fi
rm -f build/quest/run/*.wav
"$ADB" exec-out "run-as $package sh -c 'cd files/user && tar -cf - screenshots log rt_dump *.wav 2>/dev/null'" \
| tar -xf - -C build/quest/run 2>/dev/null
"$ADB" exec-out "run-as $package cat files/top.txt 2>/dev/null" > build/quest/run/top.txt
"$ADB" exec-out "run-as $package cat files/mem.txt 2>/dev/null" > build/quest/run/mem.txt
if [[ -n "$trace_at" ]]; then
"$ADB" exec-out "run-as $package cat files/gputrace.txt 2>/dev/null" > build/quest/run/gputrace.txt
fi
ls build/quest/run/screenshots 2>/dev/null | wc -l
tail -1 build/quest/run/core.log
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# The game's save is the same on the headset's own app and on the PC build: this copies the
# headset's to the PC, so that what was played there goes on here. The headset has to be on
# the cable; nothing on it is changed. A save the PC build already has is kept next to the new
# one.
#
# tools/quest-save-to-pc.sh
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
package=com.astrobotquest.vrhost
remote=/sdcard/Android/data/$package/files/data/shadPS4/home/1000/savedata/CUSA12392
local_dir="$root/pc-vr/user/home/1000/savedata/CUSA12392"
stamp=$(date +%Y%m%d-%H%M%S)
if ! "$ADB" get-state > /dev/null 2>&1; then
echo "the headset is not connected (adb sees no device $ANDROID_SERIAL)"
exit 1
fi
files=$("$ADB" shell "cd $remote 2>/dev/null && find . -type f" | tr -d '\r')
if [ -z "$files" ]; then
echo "the headset's app has no save ($remote)"
exit 1
fi
if [ -d "$local_dir" ]; then
mv "$local_dir" "$local_dir.before-$stamp"
echo "the PC's own save was kept as $(basename "$local_dir.before-$stamp")"
fi
for file in $files; do
file=${file#./}
mkdir -p "$local_dir/$(dirname "$file")"
# (Not "adb pull": with path conversion off, adb.exe is given a path it cannot write to.)
"$ADB" exec-out "cat $remote/$file" > "$local_dir/$file"
done
echo "the headset's save is in $local_dir:"
find "$local_dir" -type f -exec ls -la {} +
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Runs the host app's self test on a connected Quest: the app's own code starts the core, feeds it
# a controller and a moving head over its sockets, and reads the frames the core renders into the
# buffers the two share, exactly as it does with a headset on, minus the OpenXR session. Nobody
# has to wear the headset. The frames that arrived are pulled to build/quest/selftest/ as PNGs
# (both eyes, half size), together with the core's log.
#
# tools/quest-selftest.sh <seconds> [NAME=value ...] (variables for the core)
#
# ASTRO_FRESH=1 runs with the runtime as the installed app carries it (unpacked from the APK's
# assets, like on the app's first start) instead of with the build in build/arm64.
#
# HOST_... among the variables are the test's own (README-QUEST-VR.md, "Testing on the headset
# without wearing it"): a display and a compositor stand-in, where the head looks, what is
# said of the controller (HOST_PAD), how often a picture is kept (HOST_SHOTS).
#
# ASTRO_MIC=real makes the game's microphone the headset's own instead of the test signal the
# app otherwise feeds it (a second of noise every five); the app needs the permission for it
# (adb shell pm grant com.astrobotquest.vrhost android.permission.RECORD_AUDIO).
#
# Needs the app installed (quest-host/build.sh) and the game in /data/local/tmp/astro/games.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
# The headset also shows up over the network: name the USB connection.
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
# (ASTRO_PACKAGE: the build made by quest-host/build.sh --test-package, if that is the
# one to run.)
package=${ASTRO_PACKAGE:-com.astrobotquest.vrhost}
stage=/data/local/tmp/astro
seconds=${1:-60}
shift $(( $# < 1 ? $# : 1 ))
# What the app itself gives the core unless vrhost.txt says otherwise; settings given here come
# after these and replace them.
extra_env="SHADPS4_MAX_MSAA=1 TU_DEBUG=sysmem KGSL_COMPAT_PRIORITY=12 SHADPS4_THREAD_NICE=Game:Main=-14,shadPS4:GpuComm=-14,sndx_out_thread=-16,sndx_audio3d=-16,shadPS4:AudioOu=-16,shadPS4:VrHostL=-18,shadPS4:Present=-18 SHADPS4_FRAME_STATS=1 SHADPS4_AUDIOOUT_STATS=1 SHADPS4_AUDIOIN_STATS=1 SHADPS4_VR_REFRESH_RATE=90 SHADPS4_VR_SHARPEN=0.6 $*"
cd "$root" || exit 1
mkdir -p build/quest/selftest
tools/llvm/bin/llvm-strip.exe -o build/quest/shadps4-stripped build/arm64/shadps4 || exit 1
tools/llvm/bin/aarch64-linux-gnu-clang.exe -O2 -fPIC -shared quest-host/runtime/kgsl_compat.c \
-o build/quest/libkgsl_compat.so -lpthread || exit 1
# From here on Git Bash must leave the device paths alone.
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
"$ADB" push build/quest/shadps4-stripped $stage/shadps4-arm64-fex | tail -1
"$ADB" push build/quest/libkgsl_compat.so $stage/libkgsl_compat.so > /dev/null
"$ADB" shell "chmod 644 $stage/shadps4-arm64-fex $stage/libkgsl_compat.so"
install=""
if [ -n "${ASTRO_FRESH:-}" ]; then
install="-e install 1"
else
# The runtime the app unpacks on its first start, with the current build of the core in it.
# Without its stamp, so that the app unpacks its own again when it is started for real.
"$ADB" shell "am instrument -w -e cmd 'mkdir -p runtime/host; rm -f runtime/.stamp; [ -d runtime/drivers ] || cp -r $stage/runtime/. runtime/; cp $stage/shadps4-arm64-fex runtime/host/shadps4-arm64-fex; cp $stage/libkgsl_compat.so runtime/host/libkgsl_compat.so' $package/com.astrobotquest.vrhost.SandboxShell" > /dev/null
fi
"$ADB" shell "am instrument -w -e selftest $seconds -e env '$extra_env' -e mic ${ASTRO_MIC:-test} $install $package/com.astrobotquest.vrhost.SandboxShell" \
| sed -e 's/^INSTRUMENTATION_RESULT: stream=//' -e '/^INSTRUMENTATION_CODE/d'
rm -rf build/quest/selftest/*
"$ADB" exec-out "run-as $package sh -c 'cd files/selftest && tar -cf - *.png core.log 2>/dev/null'" \
| tar -xf - -C build/quest/selftest 2>/dev/null
"$ADB" exec-out "run-as $package sh -c 'cd files/selftest && tar -cf - host.log 2>/dev/null'" \
| tar -xf - -C build/quest/selftest 2>/dev/null
ls build/quest/selftest | head -40
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Records what a real session of the app looks like from the outside, on a Quest connected by
# USB: waits for the app to be started in the headset, then, until it ends, keeps
#
# session/logcat.txt the app's log and the VR runtime's own lines about it (frame timing,
# processor and GPU levels) as they are written
# session/gpu.txt every five seconds: seconds, GPU load %, GPU clock MHz, GPU temperature,
# clock of every processor core in MHz
# session/threads.txt every thirty seconds: the emulator's busiest threads and the cores
# they last ran on
#
# and afterwards pulls the app's host.log and core.log next to them. All of it is in
# build/quest/session/. The app cannot read the GPU's state itself, a shell can.
#
# tools/quest-session-watch.sh [seconds to wait for the app, default 7000]
#
# Nothing on the headset is changed. Stop it with Ctrl+C at any time.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
package=com.astrobotquest.vrhost
wait_seconds=${1:-7000}
out="$root/build/quest/session"
app_pid() {
"$ADB" shell "pidof $package" 2>/dev/null | tr -d '\r'
}
echo "waiting up to $wait_seconds s for $package to start"
waited=0
while [ -z "$(app_pid)" ]; do
if [ "$waited" -ge "$wait_seconds" ]; then
echo "the app was not started"
exit 3
fi
sleep 5
waited=$((waited + 5))
done
echo "the app runs (after $waited s)"
rm -rf "$out"
mkdir -p "$out"
# Only what is logged from now on; the log already on the headset is left alone.
"$ADB" logcat -T 1 -v time AstroVR:V VrApi:V crcs:V OpenXR:I ActivityManager:I AAudio:W '*:S' \
> "$out/logcat.txt" 2>&1 &
logcat_pid=$!
"$ADB" shell 'start=$(date +%s); while pidof '"$package"' > /dev/null; do
echo "$(( $(date +%s) - start )) $(cat /sys/class/kgsl/kgsl-3d0/gpu_busy_percentage | tr -d " %") $(( $(cat /sys/class/kgsl/kgsl-3d0/gpuclk) / 1000000 )) $(( $(cat /sys/class/kgsl/kgsl-3d0/temp) / 1000 )) $(cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq | while read f; do printf "%s " $(( f / 1000 )); done)"
sleep 5; done' > "$out/gpu.txt" 2>&1 &
gpu_pid=$!
"$ADB" shell 'start=$(date +%s); while pidof '"$package"' > /dev/null; do
core=$(pidof libastro_ld.so)
if [ -n "$core" ]; then
echo "== $(( $(date +%s) - start )) s"
top -H -b -n 1 -m 24 -o PID,TID,%CPU,S,PR,NI,CMD,PCY,CPU -p $core 2>&1 | tail -n +6
echo "cpus allowed: $(grep Cpus_allowed_list /proc/$core/status | cut -f2), cpuset $(cat /proc/$core/cpuset)"
fi
sleep 30; done' > "$out/threads.txt" 2>&1 &
threads_pid=$!
# Until the app is gone (or this is interrupted).
trap 'kill $logcat_pid $gpu_pid $threads_pid 2>/dev/null' EXIT
while [ -n "$(app_pid)" ]; do
sleep 5
done
echo "the app ended"
sleep 2
kill $logcat_pid 2>/dev/null
files=/sdcard/Android/data/$package/files
for name in host.log core.log; do
# (Not "adb pull": with path conversion off, adb.exe is given a path it cannot write to.)
"$ADB" exec-out "cat $files/$name" > "$out/$name" 2>/dev/null
[ -s "$out/$name" ] || echo "no $name"
done
ls -la "$out"
grep -a "pacing:\|hands:\|microphone\|session state\|refresh rate\|performance" "$out/host.log" 2>/dev/null | tail -40
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Asks the headset's OpenXR runtime what can be asked without anybody wearing the headset, from
# inside the installed app (its SandboxShell instrumentation):
#
# tools/quest-xrprobe.sh [level]
#
# 1 the app finds the runtime; which of the extensions the host uses it has
# 2 also: an instance is made, and the system, the recommended eye size and the names of the
# runtime's performance counters are listed
# 3 (default) the host itself runs for five seconds: session, swapchains, hand trackers and
# shared frame buffers are created as in a real start, the session (which stays idle) is
# asked for the refresh rates, the performance levels and the thread hints, and a test
# picture is copied into the swapchain's images and read back
#
# The log lands in build/quest/xrprobe.log. A session itself (frames, tracking, the display) is
# what this cannot reach.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
ADB="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe"
export ANDROID_SERIAL=${ANDROID_SERIAL:-$(cat "$(dirname "${BASH_SOURCE[0]}")/quest-serial.local" 2>/dev/null)}
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
# (ASTRO_PACKAGE: the build made by quest-host/build.sh --test-package, if that is the
# one to run.)
package=${ASTRO_PACKAGE:-com.astrobotquest.vrhost}
level=${1:-3}
"$ADB" shell "am instrument -w -e xrprobe $level $package/com.astrobotquest.vrhost.SandboxShell" \
| sed -e 's/^INSTRUMENTATION_RESULT: stream=//' -e '/^INSTRUMENTATION_CODE/d'
mkdir -p "$root/build/quest"
"$ADB" exec-out "run-as $package cat files/xrprobe.log" > "$root/build/quest/xrprobe.log" 2>/dev/null
grep -v "xr probe: extensions:" "$root/build/quest/xrprobe.log" | cut -c1-240
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Runs the Windows dev build of the core against the extracted game for a fixed time and keeps
# the log, so HLE changes can be checked without a headset.
# tools/run-win.sh [seconds] [extra shadps4 args...]
# SHADPS4_GAME=<folder> runs another copy of the game than games/CUSA12392.
set -u
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
seconds=${1:-40}
shift || true
# The core runs from its build folder: a script given relative to where this was started from
# would not be found there, and the run would silently play without input.
if [ -n "${SHADPS4_INPUT_SCRIPT:-}" ]; then
if [ ! -f "$SHADPS4_INPUT_SCRIPT" ]; then
echo "input script not found: $SHADPS4_INPUT_SCRIPT" >&2
exit 1
fi
export SHADPS4_INPUT_SCRIPT=$(cygpath -w "$(realpath "$SHADPS4_INPUT_SCRIPT")")
fi
cd "$root/build/win-x64" || exit 1
mkdir -p user
export SHADPS4_VR_DEMO=${SHADPS4_VR_DEMO:-1}
game=${SHADPS4_GAME:-$root/games/CUSA12392}
timeout "$seconds" ./shadps4.exe -g "$(cygpath -w "$game/eboot.bin")" "$@" > run-stdout.log 2>&1
echo "exit=$?" >> run-stdout.log
wc -l run-stdout.log
-7
View File
@@ -1,7 +0,0 @@
@echo off
rem FEX's generators call "python3", which Windows does not have. This runs the Python
rem named in tools\python.local (a full path, one line), or "python" from PATH.
setlocal
set "py=python"
if exist "%~dp0..\python.local" set /p py=<"%~dp0..\python.local"
"%py%" %*
-148
View File
@@ -1,148 +0,0 @@
// Turns the "Native stacks" samples the core's watchdog writes to its log into something
// readable: per thread, where its samples were taken, with emulator addresses resolved to
// functions through the unstripped build.
// node tools/symbolize-stacks.mjs [core.log] [shadps4 binary] [--depth n] [--thread name]
// [--profile]
// --profile turns the samples of the chosen thread into two tables: how often each function was
// on the stack at all, and how often it was the innermost function of the emulator itself (so
// that time spent in the C library or the GPU driver counts for whoever called them).
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const args = process.argv.slice(2);
let depth = 6;
let only = null;
let profile = false;
const positional = [];
while (args.length) {
const arg = args.shift();
if (arg === "--depth") depth = Number(args.shift());
else if (arg === "--thread") only = args.shift();
else if (arg === "--profile") profile = true;
else positional.push(arg);
}
const log = positional[0] ?? join(root, "build/quest/run/core.log");
const binary = positional[1] ?? join(root, "build/arm64/shadps4");
// The pulled log repeats its tail, so the same sample can show up twice: keep one of each.
const samples = new Map();
let current = null;
for (const line of readFileSync(log, "latin1").split("\n")) {
const header = line.match(/Native stacks, sample (\d+):/);
if (header) {
current = samples.has(header[1]) ? null : [];
if (current) samples.set(header[1], current);
continue;
}
const thread = line.match(/^ (.{16}) tid (\d+):(.*)$/);
if (!thread) {
if (!line.startsWith(" ")) current = null;
continue;
}
if (current) current.push({ name: thread[1].trim(), tid: thread[2], frames: thread[3].trim() });
}
// Addresses inside the emulator are bare, those in libraries come in brackets.
const framePattern = /\[([^\]]*)\]|(0x[0-9a-f]+)/g;
const addresses = new Set();
for (const threads of samples.values()) {
for (const thread of threads) {
for (const match of thread.frames.matchAll(framePattern)) {
if (match[2]) addresses.add(match[2]);
}
}
}
const names = new Map();
if (addresses.size) {
const list = [...addresses];
// Two lines per address: the function, then where it is.
const output = execFileSync(
join(root, "tools/llvm/bin/llvm-addr2line.exe"),
["-f", "-C", "-e", binary, ...list],
{ encoding: "utf8", maxBuffer: 1 << 26 },
);
const lines = output.split("\n").map((line) => line.trim());
list.forEach((address, i) => {
// Arguments and template arguments make names unreadably long.
let fn = lines[2 * i] || "?";
const paren = fn.indexOf("(");
if (paren > 0) fn = fn.slice(0, paren);
for (let previous; previous !== fn; ) {
previous = fn;
fn = fn.replace(/<[^<>]*>/g, "");
}
names.set(address, fn);
});
}
const describe = (frames) => {
const parts = [];
for (const match of frames.matchAll(framePattern)) {
parts.push(match[1] !== undefined ? `[${match[1]}]` : names.get(match[2]) ?? match[2]);
}
return parts.slice(0, depth).join(" < ");
};
if (profile) {
const inclusive = new Map();
const self = new Map();
let total = 0;
for (const threads of samples.values()) {
for (const thread of threads) {
if (only && !thread.name.includes(only)) continue;
if (thread.frames === "did not answer") continue;
++total;
const seen = new Set();
let innermost = null;
for (const match of thread.frames.matchAll(framePattern)) {
let name;
if (match[1] !== undefined) {
// A library frame: the library is what matters, not the offset.
const exported = match[1].split(" ")[1];
name = `[${match[1].split("+")[0]}${exported ? " " + exported : ""}]`;
} else {
name = names.get(match[2]) ?? match[2];
innermost ??= name;
}
seen.add(name);
}
for (const name of seen) inclusive.set(name, (inclusive.get(name) ?? 0) + 1);
innermost ??= "(outside the emulator)";
self.set(innermost, (self.get(innermost) ?? 0) + 1);
}
}
const table = (title, map, limit) => {
console.log(`
${title}`);
for (const [name, count] of [...map].sort((a, b) => b[1] - a[1]).slice(0, limit)) {
console.log(` ${((100 * count) / total).toFixed(1).padStart(5)}% ${name}`);
}
};
console.log(`${total} samples of ${only ?? "all threads"}`);
table("on the stack", inclusive, 70);
table("innermost emulator function", self, 60);
process.exit(0);
}
// Per thread: how many samples ended where.
const perThread = new Map();
for (const threads of samples.values()) {
for (const thread of threads) {
if (only && !thread.name.includes(only)) continue;
const key = `${thread.name} (${thread.tid})`;
const places = perThread.get(key) ?? new Map();
const place = thread.frames === "did not answer" ? "(did not answer)" : describe(thread.frames);
places.set(place, (places.get(place) ?? 0) + 1);
perThread.set(key, places);
}
}
console.log(`${samples.size} samples from ${log}`);
for (const [thread, places] of perThread) {
console.log(`\n${thread}`);
for (const [place, count] of [...places].sort((a, b) => b[1] - a[1])) {
console.log(` ${String(count).padStart(3)}x ${place}`);
}
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <cassert>
#include "core/vr/foveation_map.h"
int main() {
using namespace Core::Vr;
const Fov fov{1.0f, 1.0f, 1.0f, 1.0f};
GazeTarget gaze{0.25f, 0.75f, 0.5f, true};
const auto map = BuildFoveationMap(2048, 1024, 8, 8, fov, gaze);
assert(map.size() == 256 * 128);
assert(map[63 * 256 + 63] == 0);
assert(map[63 * 256 + 191] == 0);
assert(map[63 * 256 + 85] == 5);
assert(map[63 * 256] == 10);
const auto eye = BuildFoveationMap(1024, 1024, 8, 8, fov, gaze, false);
assert(eye.size() == 128 * 128);
assert(eye[63 * 128 + 63] == 0);
assert(eye[63 * 128 + 85] == 5);
assert(eye[63 * 128] == 10 && eye[63 * 128 + 127] == 10);
gaze.valid = false;
const auto fallback = BuildFoveationMap(2048, 1024, 8, 8, fov, gaze);
assert(fallback.front() == 0 && fallback.back() == 0);
assert(BuildFoveationMap(0, 1024, 8, 8, fov, gaze).empty());
}
-204
View File
@@ -1,204 +0,0 @@
// Checks what the Quest app makes of two hands holding a gamepad (quest-host/cpp/held_pad.h):
// where the gamepad is, which way it is turned, how far it is rolled and how far its front
// is tilted up, each with the right sign.
//
// clang-cl /std:c++latest /EHsc -fuse-ld=lld /I quest-host/cpp tools/tests/held_pad_test.cpp
// /Fe:build/tests/held_pad_test.exe
#include <cmath>
#include <cstdio>
#include <string>
#include "held_pad.h"
using namespace HeldPad;
static int failures = 0;
static void Check(bool ok, const std::string& what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str());
failures += ok ? 0 : 1;
}
static bool Near(float a, float b, float within = 0.002f) {
return std::abs(a - b) <= within;
}
static bool Near(const Vec& a, const Vec& b, float within = 0.002f) {
return Near(a.x, b.x, within) && Near(a.y, b.y, within) && Near(a.z, b.z, within);
}
static constexpr float Pi = 3.14159265f;
static float Radians(float degrees) {
return degrees * Pi / 180.0f;
}
/// Two hands around a gamepad whose middle is at `middle`, turned `yaw` to the left and with
/// its right side `roll` above level, the hands pointing `pitch` above level.
static Hands Hold(const Vec& middle, float yaw, float roll, float pitch, float apart = 0.2f) {
const Vec right{std::cos(yaw) * std::cos(roll), std::sin(roll), -std::sin(yaw) * std::cos(roll)};
const Vec level_front{-std::sin(yaw), 0.0f, -std::cos(yaw)};
const Vec level_up = Cross(right, level_front);
const Vec pointing{level_front.x * std::cos(pitch) + level_up.x * std::sin(pitch),
level_front.y * std::cos(pitch) + level_up.y * std::sin(pitch),
level_front.z * std::cos(pitch) + level_up.z * std::sin(pitch)};
Hands hands;
hands.left_palm = Sub(middle, Scale(right, apart * 0.5f));
hands.right_palm = Sub(middle, Scale(right, -apart * 0.5f));
hands.pointing_known = true;
// Each hand points inwards as well, by a third of a right angle.
const auto inwards = [&](float side) {
const Vec v{pointing.x * std::cos(0.5f) + right.x * side * std::sin(0.5f),
pointing.y * std::cos(0.5f) + right.y * side * std::sin(0.5f),
pointing.z * std::cos(0.5f) + right.z * side * std::sin(0.5f)};
return v;
};
hands.left_pointing = inwards(1.0f);
hands.right_pointing = inwards(-1.0f);
return hands;
}
int main() {
const Vec x{1.0f, 0.0f, 0.0f}, y{0.0f, 1.0f, 0.0f}, z{0.0f, 0.0f, 1.0f};
// The rotation from three directions, and back.
{
const Quat q = FromAxes(x, y, z);
Check(Near(q.x, 0.0f) && Near(q.y, 0.0f) && Near(q.z, 0.0f) && Near(std::abs(q.w), 1.0f),
"right, up and back where they always are: no rotation");
bool all = true;
for (int i = 0; i < 200; ++i) {
// Any attitude: turned, then tilted, then rolled.
const float a = 0.37f * static_cast<float>(i), b = 0.11f * static_cast<float>(i) - 1.4f,
c = 0.23f * static_cast<float>(i);
const Vec right{std::cos(a) * std::cos(c), std::sin(c), -std::sin(a) * std::cos(c)};
const Vec level_front{-std::sin(a), 0.0f, -std::cos(a)};
const Vec level_up = Cross(right, level_front);
const Vec front{level_front.x * std::cos(b) + level_up.x * std::sin(b),
level_front.y * std::cos(b) + level_up.y * std::sin(b),
level_front.z * std::cos(b) + level_up.z * std::sin(b)};
const Vec up = Cross(right, front);
const Quat turned = FromAxes(right, up, Scale(front, -1.0f));
all = all && Near(Rotate(turned, x), right, 0.001f) && Near(Rotate(turned, y), up, 0.001f) &&
Near(Rotate(turned, z), Scale(front, -1.0f), 0.001f);
}
Check(all, "any three directions make the rotation that takes the axes to them");
}
// Held level, straight ahead.
{
const auto pose = Locate(Hold({0.0f, -0.3f, -0.4f}, 0.0f, 0.0f, 0.0f), 0.0f, 0.5f);
Check(pose.has_value() && pose->turned_known, "two hands a gamepad's width apart hold one");
Check(Near(pose->position, {0.0f, -0.285f, -0.435f}),
"its middle is a little ahead of and above the middle between the palms");
Check(Near(pose->yaw, 0.0f) && Near(pose->pitch, 0.0f) && pose->pitch_measured,
"level hands pointing ahead: neither turned nor tilted");
Check(Near(Rotate(pose->orientation, {0.0f, 0.0f, -1.0f}), {0.0f, 0.0f, -1.0f}) &&
Near(Rotate(pose->orientation, y), y),
"its front points straight ahead, its face up");
}
// Not a gamepad.
Check(!Locate(Hold({}, 0.0f, 0.0f, 0.0f, 0.4f), 0.0f, 0.0f).has_value(),
"hands further apart than a gamepad is wide hold none");
Check(!Locate(Hold({}, 0.0f, 0.0f, 0.0f, 0.03f), 0.0f, 0.0f).has_value(),
"nor do hands that touch");
// Turned to the left.
{
const auto pose = Locate(Hold({0.1f, -0.3f, -0.4f}, Radians(30.0f), 0.0f, 0.0f), 0.0f, 0.0f);
Check(pose && Near(pose->yaw, Radians(30.0f)), "turned to the left: a heading to the left");
const Vec front = Rotate(pose->orientation, {0.0f, 0.0f, -1.0f});
Check(Near(front, {-0.5f, 0.0f, -std::cos(Radians(30.0f))}), "its front points to the left");
Check(Near(pose->position.x, 0.1f - 0.5f * 0.035f) &&
Near(pose->position.z, -0.4f - std::cos(Radians(30.0f)) * 0.035f),
"and its middle is ahead of the palms the way it points");
}
{
const auto pose = Locate(Hold({}, Radians(-45.0f), 0.0f, 0.0f), 0.0f, 0.0f);
Check(pose && Near(pose->yaw, Radians(-45.0f)) &&
Rotate(pose->orientation, {0.0f, 0.0f, -1.0f}).x > 0.7f,
"turned to the right: a heading to the right");
}
// Rolled: the right hand higher.
{
const auto pose = Locate(Hold({}, 0.0f, Radians(20.0f), 0.0f), 0.0f, 0.0f);
const Vec right = Rotate(pose->orientation, x);
const Vec front = Rotate(pose->orientation, {0.0f, 0.0f, -1.0f});
Check(pose && Near(right.y, std::sin(Radians(20.0f))), "the right hand higher: its right side is up");
Check(Near(front, {0.0f, 0.0f, -1.0f}) && Near(pose->pitch, 0.0f),
"and its front still points level and ahead");
}
// Tilted: the hands point up.
{
const auto pose = Locate(Hold({}, 0.0f, 0.0f, Radians(30.0f)), 0.0f, 0.0f);
const Vec front = Rotate(pose->orientation, {0.0f, 0.0f, -1.0f});
Check(pose && pose->pitch_measured && Near(pose->pitch, Radians(30.0f)),
"hands pointing up: tilted up by as much, however far they point inwards");
Check(Near(front, {0.0f, 0.5f, -std::cos(Radians(30.0f))}), "its front points up");
Check(Rotate(pose->orientation, y).z > 0.49f, "and its face leans towards the player");
}
{
const auto pose = Locate(Hold({}, 0.0f, 0.0f, Radians(-40.0f)), 0.0f, 0.0f);
Check(pose && Near(pose->pitch, Radians(-40.0f)) &&
Rotate(pose->orientation, {0.0f, 0.0f, -1.0f}).y < -0.6f,
"hands pointing down: tilted down");
}
{
const auto pose = Locate(Hold({}, 0.0f, 0.0f, Radians(30.0f)), Radians(-10.0f), 0.0f);
Check(pose && Near(pose->pitch, Radians(30.0f)) &&
Near(Rotate(pose->orientation, {0.0f, 0.0f, -1.0f}).y, std::sin(Radians(20.0f))),
"a gamepad taken to point lower than the hands does, by so much");
}
// All at once.
{
const float yaw = Radians(25.0f), roll = Radians(-15.0f), pitch = Radians(35.0f);
const auto pose = Locate(Hold({0.05f, -0.2f, -0.5f}, yaw, roll, pitch), 0.0f, 0.0f);
const Vec right = Rotate(pose->orientation, x);
const Vec front = Rotate(pose->orientation, {0.0f, 0.0f, -1.0f});
const Vec up = Rotate(pose->orientation, y);
Check(pose && Near(pose->yaw, yaw) && Near(pose->pitch, pitch) && Near(right.y, std::sin(roll)),
"turned, rolled and tilted at once: each is told apart");
Check(Near(Dot(right, front), 0.0f) && Near(Dot(right, up), 0.0f) && Near(Dot(front, up), 0.0f) &&
Near(Length(right), 1.0f) && Near(Length(front), 1.0f),
"and what comes out is a rotation");
}
// What the headset cannot tell.
{
Hands hands = Hold({}, 0.0f, 0.0f, Radians(50.0f));
hands.pointing_known = false;
const auto pose = Locate(hands, 0.0f, Radians(20.0f));
Check(pose && pose->turned_known && !pose->pitch_measured && Near(pose->pitch, Radians(20.0f)) &&
Near(Rotate(pose->orientation, {0.0f, 0.0f, -1.0f}).y, std::sin(Radians(20.0f))),
"where the hands point unknown: tilted the way it was taken to be");
}
{
Hands hands;
hands.left_palm = {0.0f, -0.4f, -0.4f};
hands.right_palm = {0.02f, -0.2f, -0.4f};
const auto pose = Locate(hands, 0.0f, 0.0f);
Check(pose && !pose->turned_known && Near(pose->position, {0.01f, -0.285f, -0.4f}),
"one hand above the other: somewhere, but turned no telling how");
}
// Following, a little behind.
{
const Quat quarter = FromAxes({0.0f, 0.0f, -1.0f}, y, x);
const Quat half = Towards(Quat{}, quarter, 0.5f);
const Vec front = Rotate(half, {0.0f, 0.0f, -1.0f});
Check(Near(front, {-std::sqrt(0.5f), 0.0f, -std::sqrt(0.5f)}, 0.01f),
"half the way to a quarter turn is an eighth of a turn");
const Quat other{-quarter.x, -quarter.y, -quarter.z, -quarter.w};
const Vec same = Rotate(Towards(Quat{}, other, 0.5f), {0.0f, 0.0f, -1.0f});
Check(Near(same, front, 0.01f), "whichever of its two quaternions the attitude is given as");
Check(Near(Rotate(Towards(Quat{}, quarter, 1.0f), x), {0.0f, 0.0f, -1.0f}, 0.01f),
"all the way is there");
}
std::printf("%d failed\n", failures);
return failures == 0 ? 0 : 1;
}
-294
View File
@@ -1,294 +0,0 @@
# Loads the functions of the PC launcher (pc-vr/launch.ps1), not its main flow, and tries the
# ones that look for the game on folder layouts made up for the purpose in build/launcher-test.
# powershell -ExecutionPolicy Bypass -File tools/tests/launcher-test.ps1
# With the game in games/CUSA12392 its param.sfo is used for the layouts; without it, the
# checks that need one are left out.
$top = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$launcher = Join-Path $top "pc-vr\launch.ps1"
Add-Type -AssemblyName System.Windows.Forms
$ast = [System.Management.Automation.Language.Parser]::ParseFile($launcher, [ref]$null, [ref]$null)
foreach ($function in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) {
. ([scriptblock]::Create($function.Extent.Text))
}
$unpackFolder = ".unpacking"
$madeFor = "CUSA12392"
$realSfo = Join-Path $top "games\CUSA12392\sce_sys\param.sfo"
$haveSfo = [System.IO.File]::Exists($realSfo)
$base = Join-Path $top "build\launcher-test"
if ([System.IO.Directory]::Exists($base)) { [System.IO.Directory]::Delete($base, $true) }
$failed = 0
function Check([string]$what, $got, $want) {
if ("$got" -eq "$want") { Write-Host "ok $what" } else { $script:failed++; Write-Host "FAIL $what`n got: $got`n want: $want" }
}
function Make-Game([string]$folder, [bool]$withSfo) {
[void][System.IO.Directory]::CreateDirectory($folder)
[System.IO.File]::WriteAllText((Join-Path $folder "eboot.bin"), "x")
if ($withSfo -and $haveSfo) {
[void][System.IO.Directory]::CreateDirectory((Join-Path $folder "sce_sys"))
[System.IO.File]::Copy($realSfo, (Join-Path $folder "sce_sys\param.sfo"))
}
}
# (`$flags`: what a package's header says it is, as its four bytes at 0x78: a game's own has
# 0x0A000000 there, an update of it 0x62300000.)
function Make-Package([string]$path, [string]$contentId, [int]$size, [uint32]$flags = 0x0A000000) {
[void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($path))
$bytes = New-Object byte[] $size
$bytes[0] = 0x7F; $bytes[1] = 0x43; $bytes[2] = 0x4E; $bytes[3] = 0x54
[System.Text.Encoding]::ASCII.GetBytes($contentId).CopyTo($bytes, 0x40)
$bytes[0x78] = [byte](($flags -shr 24) -band 0xFF); $bytes[0x79] = [byte](($flags -shr 16) -band 0xFF)
[System.IO.File]::WriteAllBytes($path, $bytes)
}
# a: the plain layout
$g = "$base\a\games"; Make-Game "$g\CUSA12392" $true
Check "a games\CUSA12392\eboot.bin" (Find-Game $g) "$g\CUSA12392\eboot.bin"
if ($haveSfo) {
$info = Get-GameInfo "$g\CUSA12392\eboot.bin"
Check "a param.sfo: serial" $info["TITLE_ID"] "CUSA12392"
Check "a param.sfo: version" $info["APP_VER"] "01.00"
Check "a param.sfo: name" $info["TITLE"] "ASTRO BOT Rescue Mission"
}
# b: a dump's folder inside a folder with brackets and spaces in its name
$g = "$base\b\games"; Make-Game "$g\[ABC] my dump (1)\CUSA12392-app0" $true
Check "b two folders down, brackets in the name" (Find-Game $g) "$g\[ABC] my dump (1)\CUSA12392-app0\eboot.bin"
# c: two games, the one this is made for second
if ($haveSfo) {
$g = "$base\c\games"; Make-Game "$g\Another" $false; Make-Game "$g\Zzz" $true
Check "c prefers CUSA12392 among two" (Find-Game $g) "$g\Zzz\eboot.bin"
}
# c2: the game's update in a folder of its own beside it, found first by its name
if ($haveSfo) {
$g = "$base\c2\games"; Make-Game "$g\CUSA12392-UPDATE" $true; Make-Game "$g\Game\CUSA12392" $true
Check "c2 an update's folder is not the game" (Find-Game $g) "$g\Game\CUSA12392\eboot.bin"
}
$g = "$base\c3\games"; Make-Game "$g\CUSA12392-UPDATE" $true
Check "c3 an update's folder alone is no game" (Find-Game $g) ""
# d: only the leftovers of an unpacking
$g = "$base\d\games"; Make-Game "$g\CUSA12392\.unpacking\files\uroot" $true
Check "d half-unpacked game is not taken" (Find-Game $g) ""
# e: packages
$g = "$base\e\games"
$package = "$g\CUSA12392\[ABC]-Some.Game-CUSA12392-EUR-(1.00+)-PS4.pkg"
Make-Package $package "EP9000-CUSA12392_00-PLATFORMERVR00EU" 4096
Make-Package "$g\update.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 1024
[System.IO.File]::WriteAllText("$g\notapackage.pkg", ("x" * 300))
Check "e no unpacked game" (Find-Game $g) ""
Check "e the largest package, brackets in its name" (Find-Package $g).FullName $package
Check "e content id" (Read-PackageId $package) "EP9000-CUSA12392_00-PLATFORMERVR00EU"
Check "e a file that is no package" (Read-PackageId "$g\notapackage.pkg") ""
Check "e a game's package is not an update" (Test-UpdatePackage $package) $false
Check "e a file that is no package is not an update" (Test-UpdatePackage "$g\notapackage.pkg") $false
# e2: an update next to the game's package, and larger than it: the game's is taken
$g = "$base\e2\games"
Make-Package "$g\game.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 2048
Make-Package "$g\A-Update-v1.04.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 8192 0x62300000
Make-Package "$g\first-patch.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 4096 0x00100000
Make-Package "$g\patchgo.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 16384 0x00200000
Check "e2 an update says so" (Test-UpdatePackage "$g\A-Update-v1.04.pkg") $true
Check "e2 a first patch says so" (Test-UpdatePackage "$g\first-patch.pkg") $true
Check "e2 a PATCHGO package is an update" (Test-UpdatePackage "$g\patchgo.pkg") $true
Check "e2 the game's package before a larger update" (Find-Package $g).FullName "$g\game.pkg"
# e3: nothing but updates: the largest of them, which is then turned down for what it is
$g = "$base\e3\games"
Make-Package "$g\small.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 1024 0x62300000
Make-Package "$g\large.pkg" "EP9000-CUSA12392_00-PLATFORMERVR00EU" 4096 0x62300000
Check "e3 only updates: the largest" (Find-Package $g).FullName "$g\large.pkg"
# f: too deep, and an empty or missing games folder
$g = "$base\f\games"; Make-Game "$g\1\2\3\4" $true
Check "f four folders down is not looked at" (Find-Game $g) ""
Check "f missing folder" (Find-Game "$base\nowhere") ""
Check "f missing folder, package" (Find-Package "$base\nowhere") ""
# g: what a path given by the player turns into
$g = "$base\a\games"
Check "g a folder" (Use-Path "$base\a") "$g\CUSA12392\eboot.bin"
Check "g an eboot.bin" (Use-Path "$g\CUSA12392\eboot.bin") "$g\CUSA12392\eboot.bin"
Check "g nothing there" (Use-Path "$base\a\nothing.bin") ""
$steam = (Get-VrInstructions 'F:\steam\steamapps\common\SteamVR\steamxr_win64.json') -join "`n"
Check "SteamVR instructions name the Index" ($steam -match 'Index') $true
Check "SteamVR instructions require no Virtual Desktop" ($steam -match 'Virtual Desktop is not needed') $true
Check "SteamVR instructions preserve native DualSense input" ($steam -match 'disable Steam Input') $true
Check "SteamVR instructions explain the tracking limit" ($steam -match 'does not track bare hands') $true
Check "SteamVR instructions say how to move the gamepad" ($steam -match 'Hold the PS button') $true
$vd = (Get-VrInstructions 'C:\Program Files\Virtual Desktop Streamer\OpenXR\virtualdesktop-openxr.json') -join "`n"
Check "VDXR instructions still forward hand tracking" ($vd -match 'hand tracking forwarded') $true
Check "VDXR instructions do not describe an Index" ($vd -match 'Index') $false
$unknown = (Get-VrInstructions '') -join "`n"
Check "Unknown runtime instructions do not assume Virtual Desktop" ($unknown -match 'Virtual Desktop') $false
Check "Unknown runtime instructions say how to move the gamepad" ($unknown -match 'Hold the PS button') $true
Check "VDXR instructions say how to move the gamepad" ($vd -match 'Hold the PS button') $true
foreach ($text in @($steam, $vd, $unknown)) {
Check "Instructions say how to turn round" ($text -match 'hold L1') $true
Check "Instructions say the headset's controllers play besides a gamepad" ($text -match 'whenever they were used after it') $true
}
$script:settings = [ordered]@{}
Check "Desktop defaults to stereo" (Get-DesktopView) "stereo"
$script:settings = [ordered]@{ desktop_view = "spectator" }
Check "Spectator setting selects one eye" (Get-DesktopView) "spectator"
$script:settings = [ordered]@{ desktop_view = "unknown" }
Check "Unknown desktop view falls back to stereo" (Get-DesktopView) "stereo"
$script:settings = [ordered]@{ desktop_view = "combined"; desktop_crop = "1" }
Check "Combined setting selects both eyes" (Get-DesktopView) "combined"
foreach ($assignment in $ast.EndBlock.Statements) {
if ($assignment -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$assignment.Left.Extent.Text -in @('$env:SHADPS4_VR_DESKTOP_VIEW', '$env:SHADPS4_VR_DESKTOP_CROP')) {
. ([scriptblock]::Create($assignment.Extent.Text))
}
}
Check "Launcher exports combined view" $env:SHADPS4_VR_DESKTOP_VIEW "combined"
Check "Launcher exports crop enabled" $env:SHADPS4_VR_DESKTOP_CROP "1"
$script:settings = [ordered]@{}
foreach ($assignment in $ast.EndBlock.Statements) {
if ($assignment -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$assignment.Left.Extent.Text -eq '$env:SHADPS4_VR_DESKTOP_CROP') {
. ([scriptblock]::Create($assignment.Extent.Text))
}
}
Check "Launcher clears inherited crop by default" $env:SHADPS4_VR_DESKTOP_CROP "0"
foreach ($assignment in $ast.EndBlock.Statements) {
if ($assignment -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$assignment.Left.Extent.Text -in @('$widths', '$caps', '$gameLanguages')) {
. ([scriptblock]::Create($assignment.Extent.Text))
}
}
# Default/full and fixed caps are distinct; diagnostic overrides take precedence.
$script:settings = [ordered]@{}
$env:SHADPS4_VR_PACE = ""
$env:SHADPS4_VR_FPS_CAP = "30"
Set-FramePacing
Check "Full refresh is the default" $env:SHADPS4_VR_PACE "1"
Check "Full refresh clears inherited FPS cap" $env:SHADPS4_VR_FPS_CAP ""
$script:settings = [ordered]@{ fps = "60" }
$env:SHADPS4_VR_PACE = ""
Set-FramePacing
Check "Numeric selection uses adaptive pacing" $env:SHADPS4_VR_PACE "0"
Check "Numeric selection sets FPS cap" $env:SHADPS4_VR_FPS_CAP "60"
$env:SHADPS4_VR_PACE = "1"
Set-FramePacing
Check "Full-refresh diagnostic overrides numeric selection" $env:SHADPS4_VR_FPS_CAP ""
$env:SHADPS4_VR_PACE = "0"
$script:settings = [ordered]@{ fps = "full" }
Set-FramePacing
Check "Adaptive diagnostic overrides Full framerate selection" $env:SHADPS4_VR_PACE "0"
Check "Higher resolution 3960 is offered" ($widths -contains 3960) $true
Check "Higher resolution 4320 is offered" ($widths -contains 4320) $true
Check "3960 eye height" (EyeHeight 3960) 4224
Check "4320 eye height" (EyeHeight 4320) 4608
# The Visual C++ runtime: looked for where a 64-bit program finds it, whatever PowerShell this is.
$runtimeFolder = Join-Path $base "system"
$besideFolder = Join-Path $base "beside"
[void][System.IO.Directory]::CreateDirectory($runtimeFolder)
[void][System.IO.Directory]::CreateDirectory($besideFolder)
$runtimeFiles = @("vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll")
Check "An empty system folder lacks all of the runtime" ((Get-MissingRuntime $runtimeFolder "") -join ",") ($runtimeFiles -join ",")
foreach ($name in $runtimeFiles) { if ($name -ne "vcruntime140_1.dll") { [System.IO.File]::WriteAllText((Join-Path $runtimeFolder $name), "x") } }
Check "The 32-bit runtime alone lacks the file that only exists in 64 bits" ((Get-MissingRuntime $runtimeFolder "") -join ",") "vcruntime140_1.dll"
[System.IO.File]::WriteAllText((Join-Path $besideFolder "vcruntime140_1.dll"), "x")
Check "A file next to the emulator counts" (Get-MissingRuntime $runtimeFolder $besideFolder).Count 0
$is64 = [Environment]::Is64BitProcess
Check "This PC's own runtime is found from this PowerShell (64-bit: $is64)" ((Get-MissingRuntime "" "") -join ",") ""
if ($is64) {
Check "A 64-bit PowerShell hands over to no other" (Get-NativePowerShell) ""
} else {
Check "A 32-bit PowerShell knows the 64-bit one to hand over to" ([System.IO.File]::Exists((Get-NativePowerShell))) $true
}
# The game's language.
$script:settings = [ordered]@{}
Check "The language is Windows' own unless one is set" (Get-GameLanguage) ([System.Globalization.CultureInfo]::CurrentUICulture.Name)
$script:settings = [ordered]@{ language = "windows" }
Check "language=windows is Windows' own" (Get-GameLanguage) ([System.Globalization.CultureInfo]::CurrentUICulture.Name)
$script:settings = [ordered]@{ language = "ja-JP" }
Check "A language that is set is the one asked for" (Get-GameLanguage) "ja-JP"
foreach ($assignment in $ast.EndBlock.Statements) {
if ($assignment -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$assignment.Left.Extent.Text -eq '$env:SHADPS4_CONSOLE_LANGUAGE') {
. ([scriptblock]::Create($assignment.Extent.Text))
}
}
Check "Launcher tells the emulator the language" $env:SHADPS4_CONSOLE_LANGUAGE "ja-JP"
Check "The game's 28 languages are offered" $gameLanguages.Count 28
Check "Each has a name" (@($gameLanguages | Where-Object { (Get-LanguageName $_) -eq "" -or (Get-LanguageName $_) -eq $_ }).Count) 0
$script:settings = [ordered]@{}
$SettingsFile = Join-Path $base 'menu-settings.txt'
$script:menuAction = "choose"
function Show-Form($form) {
$script:menuForm = $form
$view = $form.Controls["desktopView"]
$crop = $form.Controls["desktopCrop"]
$language = $form.Controls["language"]
$resolution = $form.Controls["resolution"]
foreach ($control in $form.Controls) {
Check "Menu bounds contain $($control.Text) $($control.Name)" $form.ClientRectangle.Contains($control.Bounds) $true
}
Check "FPS selector includes Full framerate" $form.Controls["fps"].Items[0] "Full framerate (headset refresh)"
Check "Menu has FPS selector" (@($form.Controls | Where-Object { $_.Text -eq 'Maximum framerate (FPS)' }).Count) 1
Check "Menu offers three desktop modes" $view.Items.Count 3
Check "Menu offers Windows' language and the game's 28" $language.Items.Count 29
foreach ($control in $form.Controls) {
if ($control -ne $language -and $control.Bounds.IntersectsWith($language.Bounds)) {
Check "Language list overlaps $($control.Text) $($control.Name)" $true $false
}
}
Check "Desktop view and crop controls do not overlap" $view.Bounds.IntersectsWith($crop.Bounds) $false
if ($script:menuAction -eq "choose") {
Check "Menu defaults to Full framerate" $form.Controls["fps"].SelectedIndex 0
Check "Menu defaults to Windows' language" $language.SelectedIndex 0
$resolution.Value = $widths.Count - 1
$language.SelectedIndex = 1 + [array]::IndexOf($gameLanguages, "fr-FR")
Check "Menu defaults to stereo" $view.SelectedIndex 0
Check "Menu defaults to uncropped image" $crop.Checked $false
Check "Stereo disables crop control" $crop.Enabled $false
$view.SelectedIndex = 1
Check "Single eye enables crop control" $crop.Enabled $true
$view.SelectedIndex = 2
Check "Combined eyes enable crop control" $crop.Enabled $true
$crop.Checked = $true
return [System.Windows.Forms.DialogResult]::OK
}
Check "Menu restores highest resolution" $widths[$resolution.Value] 4320
Check "Menu restores French" $language.SelectedIndex (1 + [array]::IndexOf($gameLanguages, "fr-FR"))
if ($script:menuAction -eq "restore") { $language.SelectedIndex = 0 }
Check "Menu restores combined eyes" $view.SelectedIndex 2
Check "Menu restores crop" $crop.Checked $true
$view.SelectedIndex = 0
Check "Switching to stereo disables crop" $crop.Enabled $false
$crop.Checked = $false
if ($script:menuAction -eq "cancel") { return [System.Windows.Forms.DialogResult]::Cancel }
return [System.Windows.Forms.DialogResult]::OK
}
Read-Settings
Check "Menu accepts combined eyes" (Show-Menu) $true
$script:menuForm.Dispose()
Check "Menu saves combined eyes" (Setting "desktop_view") "combined"
Check "Menu saves highest resolution" (Setting "resolution") "4320"
Check "Menu saves French" (Setting "language") "fr-FR"
Check "Menu saves crop" (Setting "desktop_crop") "1"
$script:menuAction = "cancel"
Check "Menu cancellation does not start game" (Show-Menu) $false
$script:menuForm.Dispose()
Read-Settings
Check "Cancel preserves combined eyes" (Get-DesktopView) "combined"
Check "Cancel preserves crop" (Setting "desktop_crop") "1"
$script:menuAction = "restore"
Check "Menu accepts stereo again" (Show-Menu) $true
$script:menuForm.Dispose()
Check "Menu saves stereo again" (Get-DesktopView) "stereo"
Check "Menu saves Windows' language again" (Setting "language") "windows"
Check "Menu saves uncropped image again" (Setting "desktop_crop") "0"
[System.IO.Directory]::Delete($base, $true)
"failed: $failed"
if ($failed -gt 0) { exit 1 }
-683
View File
@@ -1,683 +0,0 @@
// Checks what the Quest app makes of the controllers a player has (quest-host/cpp/pad_router.h):
// which of a gamepad and the headset's own controllers the game is played with, what the
// headset's controllers' buttons and sticks become, the right stick as a finger on the
// touchpad, the buttons that do the touchpad's gestures, and what buttons mean about the view
// besides.
//
// clang-cl /std:c++latest /EHsc -fuse-ld=lld /I quest-host/cpp /I shadps4-arm64-main/src/input
// tools/tests/pad_router_test.cpp /Fe:build/tests/pad_router_test.exe
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
#include "pad_router.h"
static int failures = 0;
static void Check(bool ok, const std::string& what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str());
failures += ok ? 0 : 1;
}
/// A router with what it sends to the game and what it says kept for looking at.
struct Bench {
PadRouter router;
PadRouter::Output out;
int sent{};
std::vector<std::string> notices;
double time{};
Bench() {
router.SetSink([this](const PadRouter::Output& output) {
out = output;
++sent;
});
router.SetNotice([this](const char* text) { notices.emplace_back(text); });
}
/// Time passes, as it does once for every picture the headset shows.
void Pass(double seconds, const TouchState* touch = nullptr) {
const double until = time + seconds;
while (time < until - 1e-9) {
time += 1.0 / 90.0;
if (touch != nullptr) {
router.SetTouch(*touch, time);
} else {
router.Tick(time);
}
}
}
bool Said(const std::string& part) const {
for (const std::string& notice : notices) {
if (notice.find(part) != std::string::npos) {
return true;
}
}
return false;
}
PadRouter::Source Source() {
return router.Look().source;
}
};
static TouchState Held() {
TouchState touch;
touch.present = true;
return touch;
}
int main() {
using Source = PadRouter::Source;
namespace Button = PadButton;
{
Bench bench;
bench.router.SetPlaying(true, 0.0);
bench.Pass(0.1);
Check(bench.Source() == Source::None, "nothing to play with: no controller");
const PadState& pad = bench.out.pad;
Check(bench.sent == 1 && pad.buttons == 0 && pad.left_x == 128 && pad.left_y == 128 &&
pad.right_x == 128 && pad.right_y == 128 && pad.left_trigger == 0 &&
pad.right_trigger == 0 && !pad.touch_down && !pad.has_motion && !bench.out.blowing,
"and the game is given a resting gamepad");
}
// --- a gamepad on its own ---------------------------------------------------------------
{
Bench bench;
bench.router.SetGamepadConnected(true, true, 0.0);
bench.router.SetPlaying(true, 0.0);
Check(bench.Source() == Source::Gamepad, "a gamepad alone is what the game is played with");
GamepadState pad;
pad.buttons = Button::Square | Button::L1;
pad.left_x = 1.0f;
pad.left_y = -1.0f;
pad.left_trigger = 0.5f;
bench.router.SetGamepad(pad, bench.time);
Check(bench.out.pad.buttons == (Button::Square | Button::L1), "its buttons are the game's");
Check(bench.out.pad.left_x == 255 && bench.out.pad.left_y == 1 && bench.out.pad.right_x == 128 &&
bench.out.pad.right_y == 128,
"its sticks are the game's (right and towards the player are the high numbers)");
Check(bench.out.pad.left_trigger == 128 && bench.out.pad.right_trigger == 0, "and its triggers");
Check(!bench.out.pad.has_motion, "a gamepad that says nothing of how it is held has no motion sensors");
Check(bench.router.TakeRecenter() == 0, "square and L1 mean nothing about the view");
const float gyro[3]{0.1f, 0.2f, 0.3f};
const float accel[3]{1.0f, 9.0f, 2.0f};
bench.router.SetGamepadMotion(gyro, accel, bench.time);
Check(bench.out.pad.has_motion && bench.out.pad.gyro[1] == 0.2f && bench.out.pad.accel[2] == 2.0f,
"its motion sensors are passed on");
Check(bench.router.Look().gamepad_motion && bench.router.Look().gamepad_accel[1] == 9.0f,
"and the host knows it has them");
// The first press of X.
pad = {};
pad.buttons = Button::Cross;
bench.router.SetGamepad(pad, bench.time);
Check(bench.router.TakeRecenter() == PadRouter::RecenterSeat, "the first press of X takes the seat");
Check(bench.Said("first press of X"), "and says so");
bench.router.SetGamepad({}, bench.time);
bench.router.SetGamepad(pad, bench.time);
Check(bench.router.TakeRecenter() == 0, "the second does not");
// OPTIONS.
pad = {};
pad.buttons = Button::Options;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.5);
Check(bench.out.pad.buttons == Button::Options, "the game sees OPTIONS");
Check(bench.router.TakeRecenter() == 0, "OPTIONS held for half a second resets nothing");
bench.Pass(0.6);
Check(bench.router.TakeRecenter() == (PadRouter::RecenterPad | PadRouter::RecenterSeat),
"held for a second it resets the view");
bench.Pass(2.0);
Check(bench.router.TakeRecenter() == 0, "once");
bench.router.SetGamepad({}, bench.time);
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.3);
bench.router.SetGamepad({}, bench.time);
bench.Pass(1.0);
Check(bench.router.TakeRecenter() == 0, "OPTIONS pressed briefly resets nothing");
// A gamepad that is gone.
pad = {};
pad.buttons = Button::Triangle;
bench.router.SetGamepad(pad, bench.time);
bench.router.SetGamepadConnected(false, false, bench.time);
Check(bench.out.pad.buttons == 0 && !bench.out.pad.has_motion && bench.Source() == Source::None,
"nothing is held down on a gamepad that is gone");
}
// --- a gamepad's touchpad, or what stands in for it ---------------------------------------
{
Bench bench;
bench.router.SetGamepadConnected(true, true, 0.0);
bench.router.SetPlaying(true, 0.0);
GamepadState pad;
pad.touch_down = true;
pad.touch_x = 0.25f;
pad.touch_y = 1.0f;
bench.router.SetGamepad(pad, bench.time);
Check(bench.out.pad.touch_down && bench.out.pad.touch_x == 480 && bench.out.pad.touch_y == 1079,
"a finger on the gamepad's own touchpad is where it is");
pad.right_y = 1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
Check(bench.out.pad.touch_x == 480 && bench.out.pad.touch_y == 1079 && bench.out.pad.right_y == 255,
"the right stick moves no finger while a real one is on the pad");
pad.touch_down = false;
bench.router.SetGamepad(pad, bench.time);
Check(!bench.out.pad.touch_down && bench.out.pad.touch_x == 480,
"the real finger lifts from where it was, and the stick's is not down the moment it does");
pad.right_y = 0.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
// The catapult at the end of a level: pull the stick towards you and let go.
pad.right_y = 1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.05);
Check(bench.out.pad.touch_down, "the right stick pulled puts a finger on the pad");
bench.Pass(0.35);
Check(bench.out.pad.touch_down && bench.out.pad.touch_y == 1079 && bench.out.pad.touch_x == 960,
"at the pad's near edge");
Check(bench.out.pad.right_y == 255, "(a gamepad's right stick stays a stick as well)");
pad.right_y = 0.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
Check(!bench.out.pad.touch_down && bench.out.pad.touch_y == 1079,
"let go, the finger lifts there, not back in the middle: the catapult shoots");
// A button for the press.
pad = {};
pad.buttons = Button::TouchPad;
bench.router.SetGamepad(pad, bench.time);
Check(bench.out.pad.touch_down && bench.out.pad.touch_x == 960 && bench.out.pad.touch_y == 540,
"a button that presses the touchpad touches it in the middle");
bench.router.SetGamepad({}, bench.time);
Check(!bench.out.pad.touch_down, "and lets go of it");
bench.router.SetStickTouchpad(false);
pad = {};
pad.right_x = -1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.3);
Check(!bench.out.pad.touch_down && bench.out.pad.right_x == 1,
"switched off, the right stick is only a stick");
}
// --- the headset's own controllers on their own -------------------------------------------
{
Bench bench;
bench.router.SetPlaying(true, 0.0);
TouchState touch = Held();
bench.Pass(0.1, &touch);
Check(bench.Source() == Source::Touch, "the headset's controllers alone play the game once they are picked up");
Check(bench.Said("headset's own controllers"), "and the log says so");
const auto buttons = [&](const TouchState& state) {
bench.Pass(0.05, &state);
return bench.out.pad.buttons;
};
touch = Held();
touch.a = true;
Check(buttons(touch) == Button::Cross, "A is cross");
Check(bench.router.TakeRecenter() == PadRouter::RecenterSeat, "and its first press takes the seat");
touch = Held();
touch.b = true;
Check(buttons(touch) == Button::Square, "B is square");
touch = Held();
touch.x = true;
Check(buttons(touch) == Button::Circle && !bench.out.blowing, "X is circle");
touch = Held();
touch.y = true;
Check(buttons(touch) == Button::Triangle && !bench.out.blowing, "Y is triangle");
touch.x = true;
Check(buttons(touch) == 0 && bench.out.blowing, "X and Y together are neither: they blow");
touch = Held();
Check(buttons(touch) == 0 && !bench.out.blowing, "for as long as they are held");
touch.left_grip = 0.9f;
touch.right_grip = 0.4f;
Check(buttons(touch) == Button::L1, "a grip squeezed is L1, one half squeezed is nothing");
touch = Held();
touch.right_grip = 1.0f;
Check(buttons(touch) == Button::R1, "the other grip is R1");
touch = Held();
touch.left_trigger = 0.05f;
touch.right_trigger = 0.5f;
Check(buttons(touch) == Button::R2 && bench.out.pad.left_trigger == 0 && bench.out.pad.right_trigger == 128,
"a trigger pulled is R2 by as much as it is pulled; one not quite at rest is not pulled");
touch = Held();
touch.left_trigger = 1.0f;
Check(buttons(touch) == Button::L2 && bench.out.pad.left_trigger == 255, "the other trigger is L2");
touch = Held();
touch.menu = true;
Check(buttons(touch) == Button::Options, "the left controller's menu button is OPTIONS");
bench.Pass(1.1, &touch);
Check(bench.router.TakeRecenter() == (PadRouter::RecenterPad | PadRouter::RecenterSeat),
"held for a second it resets the view");
touch = Held();
touch.left_stick_in = true;
Check(buttons(touch) == Button::L3 && !bench.out.pad.touch_down, "the left stick pressed in is L3");
touch = Held();
touch.right_stick_in = true;
Check(buttons(touch) == Button::TouchPad && bench.out.pad.touch_down && bench.out.pad.touch_x == 960 &&
bench.out.pad.touch_y == 540,
"the right stick pressed in presses the touchpad, in its middle");
touch.left_stick_in = true;
Check(buttons(touch) == 0 && !bench.out.pad.touch_down, "both sticks pressed in press nothing");
Check(bench.router.TakeRecenter() == (PadRouter::RecenterPad | PadRouter::RecenterSeat),
"they reset the view");
bench.Pass(0.5, &touch);
Check(bench.router.TakeRecenter() == 0, "once");
// Sticks: the runtime has away from the player as up, a gamepad the other way round.
touch = Held();
touch.left_x = -1.0f;
touch.left_y = 1.0f;
bench.Pass(0.05, &touch);
Check(bench.out.pad.left_x == 1 && bench.out.pad.left_y == 1,
"the left stick pushed away and to the left is the gamepad's, pushed away and to the left");
// The right stick is the finger on the touchpad, and nothing else.
touch = Held();
bench.Pass(0.2, &touch);
touch.right_y = -1.0f;
bench.Pass(0.4, &touch);
Check(bench.out.pad.touch_down && bench.out.pad.touch_y == 1079 && bench.out.pad.touch_x == 960,
"the right stick pulled towards the player drags a finger to the pad's near edge");
Check(bench.out.pad.right_x == 128 && bench.out.pad.right_y == 128,
"while the game's right stick stays where it is");
touch.right_y = 0.0f;
bench.Pass(0.2, &touch);
Check(!bench.out.pad.touch_down && bench.out.pad.touch_y == 1079, "let go, the finger lifts there");
Check(!bench.out.pad.has_motion, "the headset's controllers have no motion sensors to pass on: they are tracked");
// The game loses the player's attention (the headset's menu): nothing stays held.
touch = Held();
touch.a = true;
touch.left_x = 1.0f;
bench.Pass(0.05, &touch);
TouchState away = touch;
away.present = false;
bench.Pass(0.05, &away);
Check(bench.out.pad.buttons == 0 && bench.out.pad.left_x == 128 && bench.Source() == Source::Touch,
"what was held is let go when the controllers fall silent, and they stay the controller");
}
// --- both at hand ---------------------------------------------------------------------------
{
Bench bench;
bench.router.SetGamepadConnected(true, true, 0.0);
bench.router.SetPlaying(true, 0.0);
const float gyro[3]{};
const float accel[3]{0.0f, 9.81f, 0.0f};
bench.router.SetGamepadMotion(gyro, accel, 0.0);
TouchState touch = Held();
bench.Pass(0.1, &touch);
Check(bench.Source() == Source::Gamepad, "with both at hand and neither used yet: the gamepad");
// What a hand can do without a controller in it never takes over from the gamepad.
touch.left_trigger = 1.0f;
touch.right_trigger = 1.0f;
touch.left_grip = 1.0f;
touch.right_grip = 1.0f;
touch.menu = true;
bench.Pass(0.2, &touch);
Check(bench.Source() == Source::Gamepad && bench.out.pad.buttons == 0,
"triggers, grips and the menu button of the headset's controllers do not take over");
touch = Held();
bench.Pass(0.1, &touch);
touch.a = true;
bench.Pass(0.05, &touch);
Check(bench.Source() == Source::Touch && bench.out.pad.buttons == Button::Cross,
"a button under a thumb does, and is the game's at once");
Check(!bench.out.pad.has_motion, "the gamepad's motion sensors no longer count then");
touch = Held();
bench.Pass(0.1, &touch);
bench.router.SetGamepadMotion(gyro, accel, bench.time);
Check(bench.Source() == Source::Touch && !bench.out.pad.has_motion,
"a gamepad that only lies there, sensors running, takes nothing back");
GamepadState pad;
pad.left_x = 0.3f;
bench.router.SetGamepad(pad, bench.time);
Check(bench.Source() == Source::Touch, "nor does a stick that does not rest at its centre");
pad.left_x = 0.9f;
bench.router.SetGamepad(pad, bench.time);
Check(bench.Source() == Source::Gamepad && bench.out.pad.left_x == 242 && bench.out.pad.has_motion,
"a stick pushed does");
bench.Pass(0.1, &touch);
touch.right_x = 0.9f;
bench.Pass(0.05, &touch);
Check(bench.Source() == Source::Touch, "and a stick of the headset's controllers takes it back");
pad = {};
pad.buttons = Button::Circle;
bench.router.SetGamepad(pad, bench.time);
Check(bench.Source() == Source::Gamepad && bench.out.pad.buttons == Button::Circle,
"a button of the gamepad takes over again");
pad = {};
pad.right_trigger = 0.8f;
bench.router.SetGamepad({}, bench.time);
touch = Held();
touch.b = true;
bench.Pass(0.05, &touch);
Check(bench.Source() == Source::Touch, "(back to the headset's)");
bench.router.SetGamepad(pad, bench.time);
Check(bench.Source() == Source::Gamepad, "so does a trigger of the gamepad");
// The gamepad's battery gives out.
bench.router.SetGamepadConnected(false, false, bench.time);
touch = Held();
bench.Pass(0.05, &touch);
Check(bench.Source() == Source::Touch, "the gamepad gone, the headset's controllers play on");
// It comes back, and the headset's controllers are put down.
bench.router.SetGamepadConnected(true, true, bench.time);
Check(bench.Source() == Source::Touch, "a gamepad that comes back does not take over by being there");
TouchState away;
bench.Pass(0.2, &away);
Check(bench.Source() == Source::Touch, "nor because the headset's controllers were put down");
pad = {};
pad.buttons = Button::Cross;
bench.router.SetGamepad(pad, bench.time);
Check(bench.Source() == Source::Gamepad, "its first button does");
const int switches = static_cast<int>(bench.notices.size());
bench.Pass(1.0, &away);
Check(static_cast<int>(bench.notices.size()) == switches, "and nothing changes while nothing is pressed");
}
// --- the host's own menu before the game ---------------------------------------------------
{
Bench bench;
TouchState touch = Held();
bench.Pass(0.1, &touch);
Check(bench.router.TouchMenu() == 0, "the menu: nothing held, nothing said");
touch.left_x = -0.9f;
bench.Pass(0.05, &touch);
Check(bench.router.TouchMenu() == PadRouter::MenuLeft, "the left stick to the left is left");
touch = Held();
touch.right_x = 0.9f;
bench.Pass(0.05, &touch);
Check(bench.router.TouchMenu() == PadRouter::MenuRight, "the right stick to the right is right");
touch = Held();
touch.right_trigger = 1.0f;
bench.Pass(0.05, &touch);
Check(bench.router.TouchMenu() == PadRouter::MenuConfirm, "a trigger confirms");
Check(bench.out.pad.buttons == 0 && bench.sent <= 1, "none of which reaches the game");
Check(bench.router.TakeRecenter() == 0, "or resets anything");
// The game starts with the trigger that confirmed still pulled, and A pressed besides.
touch.a = true;
bench.Pass(0.05, &touch);
Check(bench.router.TouchMenu() == PadRouter::MenuConfirm, "A confirms as well");
bench.router.SetPlaying(true, bench.time);
bench.Pass(0.2, &touch);
Check(bench.out.pad.buttons == 0 && bench.out.pad.right_trigger == 0,
"what confirmed the menu is not pressed in the game");
Check(bench.router.TakeRecenter() == 0, "and takes no seat");
touch.right_trigger = 0.0f;
bench.Pass(0.05, &touch);
Check(bench.out.pad.buttons == 0, "A stays held back while it is held");
touch.a = false;
bench.Pass(0.05, &touch);
touch.a = true;
touch.right_trigger = 1.0f;
bench.Pass(0.05, &touch);
Check(bench.out.pad.buttons == (Button::Cross | Button::R2 | Button::TouchPad) &&
bench.out.pad.right_trigger == 255 && bench.out.pad.touch_down,
"let go and pressed again, both are the game's (and the trigger presses the touchpad)");
Check(bench.router.TakeRecenter() == PadRouter::RecenterSeat, "and that press of A takes the seat");
TouchState away;
bench.Pass(0.05, &away);
Check(bench.router.TouchMenu() == 0, "controllers that were put down say nothing to a menu");
}
{
// The player chooses in the menu with the headset's controllers while a gamepad is on.
Bench bench;
bench.router.SetGamepadConnected(true, true, 0.0);
TouchState touch = Held();
bench.Pass(0.1, &touch);
Check(bench.Source() == Source::Gamepad, "a gamepad that is on is the controller before the menu");
bench.router.Use(Source::Touch, bench.time);
Check(bench.Source() == Source::Touch, "choosing in the menu with the headset's controllers makes them it");
bench.router.Use(Source::Gamepad, bench.time);
Check(bench.Source() == Source::Gamepad, "and choosing with the gamepad, the gamepad");
TouchState away;
bench.Pass(0.1, &away);
bench.router.Use(Source::Touch, bench.time);
Check(bench.Source() == Source::Gamepad, "controllers nobody holds are not chosen with");
}
// --- the touchpad's gestures, on buttons -----------------------------------------------------
{
Bench bench;
bench.router.SetPlaying(true, 0.0);
TouchState touch = Held();
bench.Pass(0.2, &touch);
Check(bench.router.Look().gesture_buttons, "the headset's controllers have no touchpad: buttons do its gestures");
// The trigger of the hand that holds the controller in the game presses the pad.
touch.right_trigger = 1.0f;
bench.Pass(0.05, &touch);
Check(bench.out.pad.buttons == (Button::R2 | Button::TouchPad) && bench.out.pad.touch_down &&
bench.out.pad.touch_x == 960 && bench.out.pad.touch_y == 540,
"the right trigger presses the touchpad, in its middle (and is still R2)");
bench.Pass(2.0, &touch);
Check((bench.out.pad.buttons & Button::TouchPad) != 0 && bench.out.pad.touch_down,
"for as long as it is pulled");
touch = Held();
bench.Pass(0.1, &touch);
Check(bench.out.pad.buttons == 0 && !bench.out.pad.touch_down, "and lets go of it");
// Its grip swipes forward, once.
touch.right_grip = 1.0f;
bench.Pass(0.03, &touch);
Check(bench.out.pad.buttons == Button::R1 && bench.out.pad.touch_down && bench.out.pad.touch_y == 863,
"the right grip puts a finger near the player's edge of the pad (and is still R1)");
int least = 1079;
for (int i = 0; i < 60; ++i) {
bench.Pass(0.011, &touch);
if (bench.out.pad.touch_down) {
least = std::min<int>(least, bench.out.pad.touch_y);
}
}
Check(least == 129 && !bench.out.pad.touch_down && bench.out.pad.buttons == Button::R1,
"takes it to the far edge and off, once, however long the grip is held");
touch = Held();
bench.Pass(0.1, &touch);
// The other hand's trigger pulls back, and lets go when it is let go.
touch.left_trigger = 1.0f;
bench.Pass(0.03, &touch);
Check(bench.out.pad.buttons == Button::L2 && bench.out.pad.touch_down && bench.out.pad.touch_y == 216,
"the left trigger puts a finger near the far edge (and is still L2)");
bench.Pass(1.0, &touch);
Check(bench.out.pad.touch_down && bench.out.pad.touch_y == 1047, "pulls it back to the near edge and holds it there");
touch = Held();
bench.Pass(0.1, &touch);
Check(!bench.out.pad.touch_down && bench.out.pad.touch_y == 1047, "and lets go there: the catapult shoots");
// A gesture comes before the stick's finger.
touch.right_y = -1.0f;
bench.Pass(0.5, &touch);
Check(bench.out.pad.touch_down && bench.out.pad.touch_y == 1079, "(the stick has pulled a finger back)");
touch.right_trigger = 1.0f;
bench.Pass(0.05, &touch);
Check(bench.out.pad.touch_down && bench.out.pad.touch_y == 540 && (bench.out.pad.buttons & Button::TouchPad) != 0,
"a button's gesture takes the pad from the stick's finger");
touch = Held();
bench.Pass(0.3, &touch);
// The other way round for a player whose left controller is the one in the game.
bench.router.SetPadHand(0);
touch.left_trigger = 1.0f;
bench.Pass(0.05, &touch);
Check(bench.out.pad.buttons == (Button::L2 | Button::TouchPad), "with the left controller as the one in the game, its trigger presses");
touch = Held();
bench.Pass(0.3, &touch);
touch.right_trigger = 1.0f;
bench.Pass(0.5, &touch);
Check(bench.out.pad.buttons == Button::R2 && bench.out.pad.touch_down && bench.out.pad.touch_y == 1047,
"and the right trigger pulls");
touch = Held();
touch.left_grip = 1.0f;
bench.Pass(0.5, &touch);
touch = Held();
bench.Pass(0.1, &touch);
touch.left_grip = 1.0f;
bench.Pass(0.03, &touch);
Check(bench.out.pad.touch_down && bench.out.pad.touch_y == 863, "and its grip swipes");
}
{
// A gamepad without a touchpad.
Bench bench;
bench.router.SetGamepadConnected(true, false, 0.0);
bench.router.SetPlaying(true, 0.0);
bench.Pass(0.2);
Check(bench.router.Look().gesture_buttons, "a gamepad without a touchpad: buttons do its gestures");
GamepadState pad;
pad.buttons = Button::R2;
pad.right_trigger = 1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.3);
Check(bench.out.pad.buttons == (Button::R2 | Button::TouchPad) && bench.out.pad.touch_down &&
bench.out.pad.right_trigger == 255,
"R2 presses the touchpad");
bench.router.SetGamepad({}, bench.time);
bench.Pass(0.2);
Check(bench.out.pad.buttons == 0 && !bench.out.pad.touch_down, "and lets go of it");
pad = {};
pad.buttons = Button::R1;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.03);
Check(bench.out.pad.buttons == Button::R1 && bench.out.pad.touch_down && bench.out.pad.touch_y == 863,
"R1 swipes forward");
bench.router.SetGamepad({}, bench.time);
bench.Pass(0.6);
Check(!bench.out.pad.touch_down && bench.out.pad.touch_y == 129, "to the far edge and off, though R1 was only tapped");
pad = {};
pad.buttons = Button::L2;
pad.left_trigger = 1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.02);
bench.router.SetGamepad({}, bench.time);
bench.Pass(0.2);
Check(bench.out.pad.touch_down, "L2 tapped pulls a finger back, all of the way");
bench.Pass(0.4);
Check(!bench.out.pad.touch_down && bench.out.pad.touch_y == 1047, "and lets go at the near edge");
// OPTIONS is counted, for a host that shows the controls with the game's pause.
const uint32_t before = bench.router.Look().options_presses;
pad = {};
pad.buttons = Button::Options;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
bench.router.SetGamepad({}, bench.time);
bench.Pass(0.1);
Check(bench.router.Look().options_presses == before + 1, "a press of OPTIONS is counted once");
}
{
// A gamepad with a touchpad of its own keeps its buttons to itself.
Bench bench;
bench.router.SetGamepadConnected(true, true, 0.0);
bench.router.SetPlaying(true, 0.0);
Check(!bench.router.Look().gesture_buttons, "a gamepad with a touchpad needs no buttons for it");
GamepadState pad;
pad.buttons = Button::R2 | Button::R1 | Button::L2;
pad.right_trigger = 1.0f;
pad.left_trigger = 1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.5);
Check(bench.out.pad.buttons == (Button::R2 | Button::R1 | Button::L2) && !bench.out.pad.touch_down,
"its triggers and shoulder buttons are only that");
}
{
// The view turns by steps: the grip that has no other meaning held, the right stick
// flicked to a side.
Bench bench;
bench.router.SetPlaying(true, 0.0);
TouchState touch = Held();
bench.Pass(0.2, &touch);
Check(bench.router.TakeTurn() == 0, "nothing turns the view by itself");
touch.right_x = 1.0f;
bench.Pass(0.3, &touch);
Check(bench.router.TakeTurn() == 0 && bench.out.pad.touch_down,
"the right stick alone is a finger on the touchpad, not a turn");
touch.right_x = 0.0f;
bench.Pass(0.6, &touch);
touch.left_grip = 1.0f;
bench.Pass(0.2, &touch);
Check(bench.router.TakeTurn() == 0, "the grip alone turns nothing");
touch.right_x = 1.0f;
bench.Pass(0.2, &touch);
Check(bench.router.TakeTurn() == 1, "the left grip held and the stick flicked right: a step to the right");
Check(!bench.out.pad.touch_down && bench.out.pad.right_x == 128,
"and the stick is no finger and no stick to the game meanwhile");
Check((bench.out.pad.buttons & Button::L1) != 0, "the grip is still L1 to the game");
bench.Pass(0.5, &touch);
Check(bench.router.TakeTurn() == 0, "a stick held to the side turns once");
touch.right_x = 0.0f;
bench.Pass(0.1, &touch);
touch.right_x = -1.0f;
bench.Pass(0.1, &touch);
touch.right_x = 0.0f;
bench.Pass(0.1, &touch);
touch.right_x = -1.0f;
bench.Pass(0.1, &touch);
Check(bench.router.TakeTurn() == -2, "two flicks to the left: two steps to the left");
Check(bench.Said("the view turns a step to the left"), "and it says so");
// The stick already at a side when the grip comes down turns nothing.
touch.left_grip = 0.0f;
touch.right_x = 0.0f;
bench.Pass(0.6, &touch);
touch.right_x = 1.0f;
bench.Pass(0.1, &touch);
touch.left_grip = 1.0f;
bench.Pass(0.3, &touch);
Check(bench.router.TakeTurn() == 0, "a stick that was at a side before the grip was held turns nothing");
// With the controller of the game in the left hand, it is the right grip.
Bench left;
left.router.SetPadHand(0);
left.router.SetPlaying(true, 0.0);
TouchState other = Held();
left.Pass(0.2, &other);
other.left_grip = 1.0f;
left.Pass(0.2, &other);
other.right_x = 1.0f;
left.Pass(0.3, &other);
Check(left.router.TakeTurn() == 0, "pad hand left: the left grip swipes, it does not turn");
other = Held();
left.Pass(1.0, &other);
other.right_grip = 1.0f;
left.Pass(0.2, &other);
other.right_x = -1.0f;
left.Pass(0.2, &other);
Check(left.router.TakeTurn() == -1, "pad hand left: the right grip held and a flick turn");
}
{
// On a gamepad it is L1, whatever else the gamepad has.
Bench bench;
bench.router.SetGamepadConnected(true, true, 0.0);
bench.router.SetPlaying(true, 0.0);
GamepadState pad;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
pad.buttons = Button::L1;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
pad.right_x = -1.0f;
bench.router.SetGamepad(pad, bench.time);
bench.Pass(0.2);
Check(bench.router.TakeTurn() == -1, "a gamepad: L1 held and the right stick flicked left turn the view");
Check(bench.out.pad.right_x == 128 && !bench.out.pad.touch_down,
"and the stick says nothing else meanwhile");
}
std::printf("%d failed\n", failures);
return failures == 0 ? 0 : 1;
}
-34
View File
@@ -1,34 +0,0 @@
# Cross-compiles for Debian arm64 (the libc the Android runtime ships) with the portable clang in
# tools/llvm and the sysroot assembled by tools/mk-sysroot.mjs.
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
get_filename_component(ASTRO_TOOLS "${CMAKE_CURRENT_LIST_DIR}" ABSOLUTE)
set(CMAKE_SYSROOT "${ASTRO_TOOLS}/sysroot-arm64")
# Hard links to clang.exe: the name selects the target, and aarch64-unknown-linux-gnu.cfg beside
# them adds the sysroot, so build steps that call the compiler without CMake's flags still
# cross-compile (libpng's configuration generator does).
set(CMAKE_C_COMPILER "${ASTRO_TOOLS}/llvm/bin/aarch64-linux-gnu-clang.exe")
set(CMAKE_CXX_COMPILER "${ASTRO_TOOLS}/llvm/bin/aarch64-linux-gnu-clang++.exe")
set(CMAKE_ASM_COMPILER "${ASTRO_TOOLS}/llvm/bin/aarch64-linux-gnu-clang.exe")
set(CMAKE_C_COMPILER_TARGET aarch64-linux-gnu)
set(CMAKE_CXX_COMPILER_TARGET aarch64-linux-gnu)
set(CMAKE_ASM_COMPILER_TARGET aarch64-linux-gnu)
set(CMAKE_AR "${ASTRO_TOOLS}/llvm/bin/llvm-ar.exe" CACHE FILEPATH "")
set(CMAKE_RANLIB "${ASTRO_TOOLS}/llvm/bin/llvm-ranlib.exe" CACHE FILEPATH "")
set(CMAKE_NM "${ASTRO_TOOLS}/llvm/bin/llvm-nm.exe" CACHE FILEPATH "")
set(CMAKE_STRIP "${ASTRO_TOOLS}/llvm/bin/llvm-strip.exe" CACHE FILEPATH "")
set(CMAKE_OBJCOPY "${ASTRO_TOOLS}/llvm/bin/llvm-objcopy.exe" CACHE FILEPATH "")
set(CMAKE_OBJDUMP "${ASTRO_TOOLS}/llvm/bin/llvm-objdump.exe" CACHE FILEPATH "")
set(CMAKE_LINKER_TYPE LLD)
set(CMAKE_FIND_ROOT_PATH "${CMAKE_SYSROOT}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
# Nothing built here can run on the build machine.
set(CMAKE_CROSSCOMPILING_EMULATOR "")
-122
View File
@@ -1,122 +0,0 @@
# Drives the PC launcher's windows from outside, the way a person would: waits for a window
# with a given title that shows a given text, prints what it says, saves a picture of it, and
# presses one of its buttons (by sending the button the click message, which needs no focus).
# tools/ui-drive.ps1 [-Title <window title>] -Steps "text|picture.png|button|seconds|type", ...
# text: what to wait for in the window ("" for any window of that title); "gone:<text>"
# waits until no window shows it any more.
# button: a message box's 6 (Yes), 7 (No), 1 (OK or Open), 2 (Cancel), or what a button of
# the launcher's own windows says ("Look again", "Play"...); empty to press nothing.
# seconds: how long to wait (60).
# type: a text put into the window's "File name" box before the button is pressed.
# Example, a package in the games folder, kept after unpacking, then the settings window:
# tools/ui-drive.ps1 -Steps "Unpack it now?||6", "gone:Unpacking the game|||600",
# "unpacked and ready||7", "Resolution of each eye|menu.png|Play"
# The windows are real and on the desktop: whoever sits at the PC sees them and can click
# them first. (Do not start the launcher minimized or hidden for this: Windows then opens a
# process's first window the same way.)
param([string[]]$Steps, [string]$Title = "Astro Bot VR")
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName System.Drawing
Add-Type -Namespace Win32 -Name Ui -MemberDefinition @"
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, string lParam);
[DllImport("user32.dll")]
public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdc, uint flags);
"@
$auto = [System.Windows.Automation.AutomationElement]
$tree = [System.Windows.Automation.TreeScope]
$everything = [System.Windows.Automation.Condition]::TrueCondition
function Find-Window([string]$text) {
$named = New-Object System.Windows.Automation.PropertyCondition($auto::NameProperty, $Title)
foreach ($window in $auto::RootElement.FindAll($tree::Children, $named)) {
try {
# (The console of a launcher started by hand has the same title.)
$class = $window.Current.ClassName
if ($class -like "*CASCADIA*" -or $class -eq "ConsoleWindowClass") { continue }
if ($text -eq "") { return $window }
foreach ($element in $window.FindAll($tree::Descendants, $everything)) {
if ($element.Current.Name -like "*$text*") { return $window }
}
} catch {}
}
return $null
}
foreach ($step in $Steps) {
$parts = $step.Split("|")
$text = $parts[0]
$picture = ""; $button = ""; $type = ""; $seconds = 60
if ($parts.Count -gt 1) { $picture = $parts[1] }
if ($parts.Count -gt 2) { $button = $parts[2] }
if ($parts.Count -gt 3 -and $parts[3] -ne "") { $seconds = [int]$parts[3] }
if ($parts.Count -gt 4) { $type = $parts[4] }
$deadline = (Get-Date).AddSeconds($seconds)
if ($text.StartsWith("gone:")) {
$text = $text.Substring(5)
while ((Find-Window $text) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 300 }
if (Find-Window $text) { "TIMEOUT: '$text' is still shown after $seconds s"; exit 1 }
"gone: '$text'"
continue
}
$window = $null
while (-not $window -and (Get-Date) -lt $deadline) {
$window = Find-Window $text
if (-not $window) { Start-Sleep -Milliseconds 200 }
}
if (-not $window) { "TIMEOUT: no window '$Title' showing '$text' within $seconds s"; exit 1 }
Start-Sleep -Milliseconds 300
"window '$Title' showing '$text':"
$elements = $window.FindAll($tree::Descendants, $everything)
foreach ($element in $elements) {
$name = $element.Current.Name
$class = $element.Current.ClassName
if ($name -and ($class -like "*Static*" -or $class -like "*Button*" -or $class -like "*STATIC*" -or $class -like "*BUTTON*")) {
" [" + $element.Current.AutomationId + "] " + ($name -replace "`r?`n", " / ")
}
}
if ($picture) {
$r = $window.Current.BoundingRectangle
if ([double]::IsInfinity($r.Width) -or $r.Width -lt 1) { "NO PICTURE: the window is minimized or hidden"; exit 1 }
$bitmap = New-Object System.Drawing.Bitmap([int]$r.Width, [int]$r.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$hdc = $graphics.GetHdc()
[void][Win32.Ui]::PrintWindow([IntPtr]$window.Current.NativeWindowHandle, $hdc, 2)
$graphics.ReleaseHdc($hdc)
$bitmap.Save($picture, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose(); $bitmap.Dispose()
}
if ($type) {
$edit = $null
# (A file dialog's "File name" box is the edit numbered 1148; it has others.)
foreach ($element in $elements) {
if ($element.Current.ClassName -eq "Edit" -and $element.Current.AutomationId -eq "1148") { $edit = $element; break }
}
if (-not $edit) {
foreach ($element in $elements) { if ($element.Current.ClassName -eq "Edit") { $edit = $element; break } }
}
if (-not $edit) { "NO EDIT BOX in that window"; exit 1 }
# WM_SETTEXT
[void][Win32.Ui]::SendMessage([IntPtr]$edit.Current.NativeWindowHandle, 0x000C, [IntPtr]::Zero, $type)
" typed: $type"
Start-Sleep -Milliseconds 300
}
if ($button) {
# By its number (a message box's buttons) or by what it says (the launcher's own). A
# message box's buttons do not take the automation's own "invoke".
$target = $null
foreach ($element in $elements) {
if ($element.Current.AutomationId -eq $button -or $element.Current.Name -eq $button) { $target = $element; break }
}
if (-not $target) { "NO BUTTON '$button' in that window"; exit 1 }
$name = $target.Current.Name
# BM_CLICK, posted: the window may close before a sent message returns.
[void][Win32.Ui]::PostMessage([IntPtr]$target.Current.NativeWindowHandle, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero)
" pressed: $name"
Start-Sleep -Milliseconds 300
}
}
"steps done"
-65
View File
@@ -1,65 +0,0 @@
# Keeps pictures of a window as it changes: so many a second for so many seconds, as JPEGs named
# by the milliseconds since the start. For looking afterwards at what happened in a game that
# somebody plays in a headset, whose window on the desktop shows what both eyes see.
# powershell -File tools/window-record.ps1 <title regex> <folder> [seconds] [pictures a second]
param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][string]$Folder,
[double]$Seconds = 20, [double]$Rate = 6)
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Text;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class Shown {
public delegate bool EnumProc(IntPtr hwnd, IntPtr lparam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, IntPtr lparam);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hwnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, uint flags);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public static IntPtr Find(string pattern) {
IntPtr found = IntPtr.Zero;
var regex = new System.Text.RegularExpressions.Regex(pattern);
EnumWindows((h, l) => {
if (!IsWindowVisible(h)) return true;
var text = new StringBuilder(512);
GetWindowText(h, text, 512);
if (regex.IsMatch(text.ToString())) { found = h; return false; }
return true;
}, IntPtr.Zero);
return found;
}
}
"@
$window = [Shown]::Find($Title)
if ($window -eq [IntPtr]::Zero) { Write-Output "no window matches '$Title'"; exit 1 }
[void][System.IO.Directory]::CreateDirectory($Folder)
$client = New-Object Shown+RECT
[void][Shown]::GetClientRect($window, [ref]$client)
$width = [Math]::Max(1, $client.Right)
$height = [Math]::Max(1, $client.Bottom)
$bitmap = New-Object System.Drawing.Bitmap $width, $height
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }
$quality = New-Object System.Drawing.Imaging.EncoderParameters 1
$quality.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), 88L
$clock = [System.Diagnostics.Stopwatch]::StartNew()
$taken = 0
while ($clock.Elapsed.TotalSeconds -lt $Seconds) {
$at = $clock.ElapsedMilliseconds
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$hdc = $graphics.GetHdc()
# 1 = client area only, 2 = also what the window draws with the GPU
[void][Shown]::PrintWindow($window, $hdc, 3)
$graphics.ReleaseHdc($hdc)
$graphics.Dispose()
$bitmap.Save((Join-Path $Folder ("{0:d6}.jpg" -f $at)), $codec, $quality)
$taken++
$next = $taken * 1000.0 / $Rate
$wait = $next - $clock.ElapsedMilliseconds
if ($wait -gt 0) { Start-Sleep -Milliseconds ([int]$wait) }
}
Write-Output ("{0} pictures of {1}x{2} in {3:N1} s -> {4}" -f $taken, $width, $height, $clock.Elapsed.TotalSeconds, $Folder)
-74
View File
@@ -1,74 +0,0 @@
# Lists the visible top-level windows, or saves a picture of the one whose title matches.
# powershell -File tools/window-shot.ps1 list windows
# powershell -File tools/window-shot.ps1 <title regex> <png> picture of the first match
param([string]$Title = "", [string]$Out = "")
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Text;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class Win {
public delegate bool EnumProc(IntPtr hwnd, IntPtr lparam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, IntPtr lparam);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hwnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr hwnd, ref POINT point);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, uint flags);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hwnd);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
public static List<IntPtr> All() {
var list = new List<IntPtr>();
EnumWindows((h, l) => { if (IsWindowVisible(h)) list.Add(h); return true; }, IntPtr.Zero);
return list;
}
public static string TitleOf(IntPtr h) {
var text = new StringBuilder(512);
GetWindowText(h, text, 512);
return text.ToString();
}
}
"@
$windows = [Win]::All() | ForEach-Object {
$rect = New-Object Win+RECT
[void][Win]::GetWindowRect($_, [ref]$rect)
$processId = 0
[void][Win]::GetWindowThreadProcessId($_, [ref]$processId)
[pscustomobject]@{
Handle = $_; Title = [Win]::TitleOf($_); Pid = $processId
Process = (Get-Process -Id $processId -ErrorAction SilentlyContinue).ProcessName
Width = $rect.Right - $rect.Left; Height = $rect.Bottom - $rect.Top
Minimized = [Win]::IsIconic($_)
}
} | Where-Object { $_.Title -ne "" }
if ($Title -eq "") {
$windows | Format-Table Process, Pid, Width, Height, Minimized, Title -AutoSize
exit 0
}
$window = $windows | Where-Object { $_.Title -match $Title -or $_.Process -match $Title } |
Select-Object -First 1
if ($null -eq $window) {
Write-Output "no window matches '$Title'"
exit 1
}
$client = New-Object Win+RECT
[void][Win]::GetClientRect($window.Handle, [ref]$client)
$width = [Math]::Max(1, $client.Right)
$height = [Math]::Max(1, $client.Bottom)
$bitmap = New-Object System.Drawing.Bitmap $width, $height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$hdc = $graphics.GetHdc()
# 1 = client area only, 2 = also what the window draws with the GPU
[void][Win]::PrintWindow($window.Handle, $hdc, 3)
$graphics.ReleaseHdc($hdc)
$bitmap.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
Write-Output ("{0} ({1}): {2}x{3} -> {4}" -f $window.Title, $window.Process, $width, $height, $Out)
-146
View File
@@ -1,146 +0,0 @@
# Holds keys down in the Meta XR Simulator's window, which is how its controllers are worked by
# hand: N and M are the right controller's A and B, X and C the left one's X and Y, Tab its menu
# button, G and H the grips, F and Semicolon the sticks pressed in, the arrow keys (or I J K L)
# the left stick. Look:<dx>,<dy> turns the simulated head (the right mouse button held while
# the mouse moves). The window is brought to the front for the time it takes and the window that
# was in front before gets its place back. Nothing is typed unless the simulator's window really
# is in front.
# powershell -File tools/xrsim-keys.ps1 <key>:<milliseconds> [<key>:<milliseconds> ...]
# Keys: Left Right Up Down W A S D R F Q E Space N M X C G H I J K L Tab Semicolon;
# Wait:<milliseconds> does nothing for that long; A+B:<milliseconds> holds two at once;
# Click:<x>,<y> clicks at that point of the window (pixels from its top left corner);
# Scroll:<x>,<y>,<notches> turns the mouse wheel there (negative: down the page);
# Mouse:right-down / Mouse:right-up hold and let go of the right mouse button
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Steps)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Keys {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hwnd, int cmd);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hwnd);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point);
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")] public static extern uint MapVirtualKey(uint code, uint type);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint from, uint to, bool attach);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
}
"@
$codes = @{ Left = 0x25; Up = 0x26; Right = 0x27; Down = 0x28; Space = 0x20
W = 0x57; A = 0x41; S = 0x53; D = 0x44; R = 0x52; F = 0x46; Q = 0x51; E = 0x45
N = 0x4E; M = 0x4D; X = 0x58; C = 0x43; G = 0x47; H = 0x48; I = 0x49; J = 0x4A
K = 0x4B; L = 0x4C; Tab = 0x09; Semicolon = 0xBA; B = 0x42; T = 0x54; U = 0x55
Y = 0x59; Comma = 0xBC }
$process = Get-Process MetaXRSimulator -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
if ($null -eq $process) { Write-Output "the simulator has no window"; exit 1 }
$window = $process.MainWindowHandle
$before = [Keys]::GetForegroundWindow()
$cursor = New-Object Keys+POINT
[void][Keys]::GetCursorPos([ref]$cursor)
if ([Keys]::IsIconic($window)) { [void][Keys]::ShowWindow($window, 9) }
$pid2 = 0
$target = [Keys]::GetWindowThreadProcessId($before, [ref]$pid2)
$mine = [Keys]::GetCurrentThreadId()
[void][Keys]::AttachThreadInput($mine, $target, $true)
[void][Keys]::SetForegroundWindow($window)
[void][Keys]::AttachThreadInput($mine, $target, $false)
Start-Sleep -Milliseconds 300
if ([Keys]::GetForegroundWindow() -ne $window) {
Write-Output "the simulator's window could not be brought to the front: nothing typed"
exit 2
}
# A click into the picture, which is what takes the keys.
$rect = New-Object Keys+RECT
[void][Keys]::GetWindowRect($window, [ref]$rect)
$x = $rect.Left + [int](($rect.Right - $rect.Left) * 0.68)
$y = $rect.Top + [int](($rect.Bottom - $rect.Top) * 0.55)
[void][Keys]::SetCursorPos($x, $y)
[Keys]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
[Keys]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 200
foreach ($step in $Steps) {
$name, $time = $step.Split(":")
if ($name -eq "Look") {
# Turning the head: the right mouse button held down while the mouse moves, a little
# at a time.
$dx, $dy = $time.Split(",")
$stepsLeft = 20
[Keys]::mouse_event(0x0008, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 120
for ($i = 0; $i -lt $stepsLeft; $i++) {
[Keys]::mouse_event(0x0001, [int]([int]$dx / $stepsLeft), [int]([int]$dy / $stepsLeft), 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 25
}
Start-Sleep -Milliseconds 120
[Keys]::mouse_event(0x0010, 0, 0, 0, [UIntPtr]::Zero)
Write-Output "looked by $dx,$dy"
continue
}
if ($name -eq "Wait") { Start-Sleep -Milliseconds ([int]$time); continue }
if ($name -eq "Mouse") {
if ($time -eq "right-down") { [Keys]::mouse_event(0x0008, 0, 0, 0, [UIntPtr]::Zero) }
if ($time -eq "right-up") { [Keys]::mouse_event(0x0010, 0, 0, 0, [UIntPtr]::Zero) }
Start-Sleep -Milliseconds 150
continue
}
if ($name -eq "Scroll") {
$sx, $sy, $notches = $time.Split(",")
[void][Keys]::SetCursorPos($rect.Left + [int]$sx, $rect.Top + [int]$sy)
Start-Sleep -Milliseconds 80
[Keys]::mouse_event(0x0800, 0, 0, [uint32]([int]$notches * 120 -band 0xffffffff), [UIntPtr]::Zero)
Start-Sleep -Milliseconds 400
Write-Output "scrolled by $notches at $sx,$sy"
continue
}
if ($name -eq "Click") {
$cx, $cy = $time.Split(",")
[void][Keys]::SetCursorPos($rect.Left + [int]$cx, $rect.Top + [int]$cy)
Start-Sleep -Milliseconds 80
[Keys]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 60
[Keys]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 300
Write-Output "clicked at $cx,$cy"
continue
}
$held = @($name.Split("+"))
if (@($held | Where-Object { -not $codes.ContainsKey($_) }).Count -ne 0) {
Write-Output "unknown key in $name"
continue
}
if ([Keys]::GetForegroundWindow() -ne $window) { Write-Output "lost the front: stopping"; break }
foreach ($key in $held) {
$code = [byte]$codes[$key]
# Arrow keys are "extended" keys. The key's place on the keyboard goes with it: some
# programs read that and not what the key means.
$flags = 0
if ($code -ge 0x25 -and $code -le 0x28) { $flags = 1 }
[Keys]::keybd_event($code, [byte][Keys]::MapVirtualKey($code, 0), $flags, [UIntPtr]::Zero)
}
Start-Sleep -Milliseconds ([int]$time)
foreach ($key in $held) {
$code = [byte]$codes[$key]
$flags = 2
if ($code -ge 0x25 -and $code -le 0x28) { $flags = 3 }
[Keys]::keybd_event($code, [byte][Keys]::MapVirtualKey($code, 0), $flags, [UIntPtr]::Zero)
}
Start-Sleep -Milliseconds 150
Write-Output "held $name for $time ms"
}
[void][Keys]::SetCursorPos($cursor.X, $cursor.Y)
if ($before -ne [IntPtr]::Zero) { [void][Keys]::SetForegroundWindow($before) }