mirror of
https://github.com/mitch030504/AstroQuest-Frame.git
synced 2026-10-17 23:01:01 +02:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4696de86 | ||
|
|
853a32b0a4 | ||
|
|
5deb3c3edc | ||
|
|
d9d845345a | ||
|
|
1933d4bba3 | ||
|
|
14826294a2 | ||
|
|
8c2d54ccaa | ||
|
|
8bac3b565d | ||
|
|
cbad4abb99 | ||
|
|
f1c075c140 | ||
|
|
a6a4620e5a | ||
|
|
d2a6c56370 | ||
|
|
6b28216ade | ||
|
|
8733d52476 | ||
|
|
66740d71ba | ||
|
|
b164648378 | ||
|
|
48cbb739f5 | ||
|
|
cf1ae09e3d | ||
|
|
8fc1593f01 | ||
|
|
09d2afee7b | ||
|
|
54b1157e20 | ||
|
|
d821d1e5b8 | ||
|
|
4fe067a1b0 | ||
|
|
ed58f2617d | ||
|
|
9404a9295d |
No files matched your search
+1
-18
@@ -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/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Play
|
||||
pause
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Play
|
||||
pause
|
||||
+260
-58
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
|
||||
[](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.
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode FullRefresh
|
||||
pause
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Original
|
||||
pause
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Recovery
|
||||
pause
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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{};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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{};
|
||||
};
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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__)
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Astro VR Host</string>
|
||||
</resources>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Executable
+114
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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}`);
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {} +
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%" %*
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "")
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
Reference in new issue
Block a user