mirror of
https://github.com/mitch030504/AstroQuest-Frame.git
synced 2026-10-18 14:01:02 +02:00
Compare commits
77
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 | ||
|
|
764e7664fc | ||
|
|
3a75299ff5 | ||
|
|
d4e4e00192 | ||
|
|
4b9479a086 | ||
|
|
8b42b96ef7 | ||
|
|
ee87d8a087 | ||
|
|
f54f39c328 | ||
|
|
2882350c07 | ||
|
|
807ca1f377 | ||
|
|
368f8be8e7 | ||
|
|
8a909b0438 | ||
|
|
f5f4c2ccf5 | ||
|
|
4270a553ab | ||
|
|
747e525ec7 | ||
|
|
d4094454ed | ||
|
|
9ff3e43060 | ||
|
|
9bcfd747e9 | ||
|
|
22b89f16f4 | ||
|
|
9a2b8b8178 | ||
|
|
ee4f9aa03f | ||
|
|
d59cd0d370 | ||
|
|
664710034a | ||
|
|
0d5bd58fe6 | ||
|
|
fb18b8d6bf | ||
|
|
d96618af1d | ||
|
|
e4144b8f15 | ||
|
|
e85686416c | ||
|
|
70b7daec41 | ||
|
|
970b8cc5f4 | ||
|
|
a985b4c68f | ||
|
|
58b684f234 | ||
|
|
2906d51254 | ||
|
|
4ddf419067 | ||
|
|
df46bd476d | ||
|
|
cb850b6a8c | ||
|
|
fca416556f | ||
|
|
6b6807de71 | ||
|
|
c3822bc97e | ||
|
|
49dd56c04c | ||
|
|
e70d5a3540 | ||
|
|
94b4e214d1 | ||
|
|
9eb257ac25 | ||
|
|
5d736504a0 | ||
|
|
bf7f9acfe3 | ||
|
|
b6244418c5 | ||
|
|
da12f98cbf | ||
|
|
46f1d3f079 | ||
|
|
8db51b8efb | ||
|
|
4866f19f4a | ||
|
|
4b8e9610f9 | ||
|
|
1c745b9611 | ||
|
|
e6e19a59f9 |
No files matched your search
+2
-18
@@ -7,28 +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,5 +0,0 @@
|
||||
@echo off
|
||||
rem ASTRO BOT Rescue Mission on this PC, shown in a headset connected through Virtual Desktop
|
||||
rem (or any other OpenXR runtime). Settings: pc-vr\settings.txt
|
||||
title Astro Bot VR
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0pc-vr\launch.ps1" %*
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Test.ps1" -Mode Recovery
|
||||
pause
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
# AstroQuest-Frame setup and build guide
|
||||
|
||||
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).
|
||||
|
||||
## Requirements
|
||||
|
||||
- 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
|
||||
sha256sum -c SHA256SUMS.txt && \
|
||||
tar -xzf AstroQuest-SteamFrame-ARM64.tar.gz && \
|
||||
bash astroquest-frame/install.sh
|
||||
```
|
||||
|
||||
For SSH access, see [Valve's developer-mode setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup#developer-mode).
|
||||
Run the installer without `sudo`.
|
||||
|
||||
The installer:
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
~/Applications/astroquest-frame/launch.sh /path/to/CUSA12431
|
||||
```
|
||||
|
||||
Extra emulator arguments may follow the game folder. Quit and relaunch after
|
||||
updating so the running game loads the new binary and bindings.
|
||||
|
||||
### Alternative: SteamOS Devkit Client
|
||||
|
||||
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:
|
||||
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
-435
@@ -1,435 +0,0 @@
|
||||
# Astro Bot Rescue Mission on the PC, shown in the headset through Virtual Desktop
|
||||
|
||||
## Experimental PC performance build
|
||||
|
||||
This fork retains upstream's Quest support and adds PC VR experiments. The fork's initial
|
||||
settings are **3600x3840 per eye and a 72 FPS cap**. Double-click `Play AstroQuest.bat` to
|
||||
choose resolution, maximum framerate and field of view before each launch. The choices are
|
||||
saved in `pc-vr/settings.txt`. Set the actual headset refresh rate separately in Virtual
|
||||
Desktop; use 72 Hz and turn SSW off for the initial native-refresh test.
|
||||
|
||||
- OpenXR uses a second queue in the same Vulkan graphics family when available, with a
|
||||
producer timeline semaphore and copy-completion fence. Set `SHADPS4_XR_SHARED_QUEUE=1`
|
||||
for the original shared queue behavior.
|
||||
- `Run Recovery.bat` sets `SHADPS4_VR_RETRY_SECONDS=30`. This permits another full-refresh
|
||||
probe sooner after temporary overload. GPU headroom checks and retry backoff remain, so
|
||||
repeated failures can delay another probe up to 120 seconds. Probes may cause judder;
|
||||
this does not make a demanding scene cheaper to render.
|
||||
- `Run Original Recovery.bat` uses the original 600-second recovery gate for comparison.
|
||||
The executable also defaults to 600 seconds unless the environment override is set.
|
||||
- `Run Full Refresh Diagnostic.bat` forces one game frame per headset refresh. This
|
||||
diagnostic ignores the selected FPS cap and can reveal throughput hidden by automatic
|
||||
half-rate fallback; it cannot guarantee native refresh and may produce uneven motion.
|
||||
- Windows CPU timing uses `GetThreadTimes`. The launchers enable periodic frame-path
|
||||
diagnostics and archive each session's logs and selected settings under `test-logs/`.
|
||||
|
||||
The separate-queue build delivered roughly 72 FPS for most of a local 72 Hz session before
|
||||
falling to 36 FPS. The shortened recovery interval has passed a governor simulation but
|
||||
has not yet been validated in a headset. Release compilation, queue synchronization and
|
||||
GUI selection/persistence checks passed. Compare identical scenes and settings, including
|
||||
an easier scene after overload, before drawing performance conclusions.
|
||||
|
||||
For a separate GPU-cost experiment, add `msaa=2` to `pc-vr/settings.txt`; the default is 4.
|
||||
No local game files, personal saves, account configuration or performance logs are published
|
||||
in this fork. The upstream documentation below describes the original defaults and launcher.
|
||||
|
||||
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).
|
||||
**Without a gamepad on the PC the headset's own controllers play** (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**, 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.
|
||||
|
||||
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).
|
||||
- A controller without a touchpad (an Xbox pad): the right stick stands in for the finger
|
||||
(flick it), and the Back / View button for the click. The right stick does that with a
|
||||
DualSense as well while nothing touches its pad; the game has no other use for it.
|
||||
- **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).
|
||||
|
||||
### With the headset's own controllers
|
||||
|
||||
While no gamepad is connected to the PC, the two Touch controllers are the gamepad:
|
||||
|
||||
| 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 stick | the finger on the touchpad: push it the way you would swipe |
|
||||
| right stick pressed in | the touchpad pressed |
|
||||
| triggers | L2, R2 |
|
||||
| grips | L1, R1 |
|
||||
| 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 game's rumble goes to both controllers. A gamepad that
|
||||
is connected to the PC takes over at once, and gives the controllers back when it goes.
|
||||
|
||||
## 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 |
|
||||
| `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 |
|
||||
| `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`) |
|
||||
| `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 the device the runtime names for the headset (Virtual Desktop has its
|
||||
own), and the game's 7.1 mix is rendered for two speakers at the ears as on the Quest; the
|
||||
microphone is the one the runtime names. Both fall back to Windows' default devices.
|
||||
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").
|
||||
|
||||
## 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`.
|
||||
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 |
|
||||
| `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
|
||||
|
||||
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,617 +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.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
|
||||
├─ maps a Bluetooth DualSense to the DualShock 4 the game expects: buttons, sticks,
|
||||
│ triggers, touchpad and motion sensors in; rumble and light bar colour out
|
||||
├─ places that controller in the game: from the hands the headset sees holding it when it
|
||||
│ can, otherwise at a fixed spot in front of the player, turned by its motion sensors
|
||||
├─ 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
|
||||
```
|
||||
|
||||
Pair the DualSense with the headset (Settings ▸ Bluetooth; hold Create + PS until the light bar
|
||||
flashes), then start **Astro VR Host** from the app library (Unknown Sources). A floating panel
|
||||
reports what the app is doing, and which controller it found, until the game's first frame arrives.
|
||||
(The headset's own controllers count as gamepads too, and were what the app took for the
|
||||
controller in the third session: the game then gets buttons and sticks from the DualSense but
|
||||
neither its motion sensors nor rumble or light bar. From 0.7 on a controller of Sony's make goes
|
||||
first whenever it shows up, and the controller is the one input actually comes from.)
|
||||
Put the Touch controllers aside: with them out of the way the headset tracks your hands, which is
|
||||
what places the DualSense in the game.
|
||||
|
||||
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. If the system does not hand it over, the right stick stands in
|
||||
for the finger (flick it) and Create for the click.
|
||||
- **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 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; the controller stays at its fixed spot |
|
||||
| `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 motion sensors / 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` | how much of PlayStation VR's field of view the game draws, in percent (50–120). **Default 85 since app 0.9**: about 91 by 94 degrees an eye instead of 100 by 103, the same pixels over fewer degrees, so 18% more of them to the degree, with a black border where the rest was (it costs the GPU nothing). `100` is what the game draws on a PlayStation VR (50.4°/49.8° out/in, 51.6° up and down an eye); `80` or `75` sharper still and narrower. host.log says what was used ("the game draws 85% of PlayStation VR's field of view") |
|
||||
| `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/`.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
## 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_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 |
|
||||
| `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), 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.
|
||||
|
||||
## 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,219 +1,165 @@
|
||||
# AstroQuest
|
||||
# AstroQuest-Frame
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
**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. Two ways to play:
|
||||
**[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)
|
||||
|
||||
- **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.
|
||||
|
||||
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.** (The Quest 3S has the same chip and should work, but nobody has tried.)
|
||||
- **A PS5 DualSense controller.** It stands in for the PS4 controller the game expects:
|
||||
buttons, sticks, touchpad, motion sensors, rumble and light bar. (A DualShock 4 may work
|
||||
too; untested.) On the PC the Quest's own Touch controllers can stand in for it.
|
||||
- **ASTRO BOT Rescue Mission, European release CUSA12392, version 1.00**, 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.) Other regions and versions are untested, and the fixes for the game's
|
||||
timing and resolution only apply to this one.
|
||||
- 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.13-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. **Pair the DualSense with the headset**: Settings > Bluetooth > Pair, and on the
|
||||
controller hold Create and the PS button until the light bar flashes.
|
||||
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:
|
||||
|
||||
- Put the Touch controllers aside and hold the DualSense: the headset tracks your hands around
|
||||
it, and that is where the controller is in the game.
|
||||
- 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) 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. With no gamepad on the PC, the Touch controllers play instead.
|
||||
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 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).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -221,14 +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.
|
||||
- [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.
|
||||
- [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:
|
||||
[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, 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('Recovery', 'Original', 'FullRefresh')][string]$Mode = 'Recovery')
|
||||
$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 { '30' }
|
||||
$env:SHADPS4_VR_PACE = if ($Mode -eq 'FullRefresh') { '1' } 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 maximum framerate in the settings window. Keep Virtual Desktop SSW off."
|
||||
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
-50
@@ -1,56 +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).
|
||||
|
||||
## 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,862 +0,0 @@
|
||||
# Starts ASTRO BOT Rescue Mission in the emulator for a headset connected to this PC (Virtual
|
||||
# Desktop, or anything else with an OpenXR runtime), and tells what is going on while it runs.
|
||||
# Started by "Play Astro Bot VR.bat"; settings are in settings.txt next to this file, and the
|
||||
# main ones can be chosen in a small window before the game starts.
|
||||
param([string]$SettingsFile = "", [switch]$NoMenu, [switch]$ForceMenu)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$root = Split-Path -Parent $here
|
||||
Set-Location $here
|
||||
if ($SettingsFile -eq "") { $SettingsFile = Join-Path $here "settings.txt" }
|
||||
|
||||
function Say([string]$text, [string]$color = "Gray") { Write-Host $text -ForegroundColor $color }
|
||||
|
||||
# --- settings -------------------------------------------------------------------------------
|
||||
function Read-Settings {
|
||||
$script:settings = [ordered]@{}
|
||||
$script:extraEnv = @()
|
||||
if (Test-Path $SettingsFile) {
|
||||
foreach ($line in Get-Content $SettingsFile) {
|
||||
$line = $line.Trim()
|
||||
if ($line -eq "" -or $line.StartsWith("#")) { continue }
|
||||
$at = $line.IndexOf("=")
|
||||
if ($at -lt 1) { continue }
|
||||
$key = $line.Substring(0, $at).Trim().ToLower()
|
||||
$value = $line.Substring($at + 1).Trim()
|
||||
if ($key -eq "env") { $script:extraEnv += $value } else { $script:settings[$key] = $value }
|
||||
}
|
||||
}
|
||||
}
|
||||
function Setting([string]$key, [string]$default = "") {
|
||||
if ($settings.Contains($key)) { return $settings[$key] }
|
||||
return $default
|
||||
}
|
||||
# Writes key=value into the settings file: in place of the line that sets it, or at the end.
|
||||
function Save-Setting([string]$key, [string]$value) {
|
||||
$lines = @()
|
||||
if (Test-Path $SettingsFile) { $lines = @(Get-Content $SettingsFile) }
|
||||
$done = $false
|
||||
for ($i = 0; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -match ("^\s*" + [regex]::Escape($key) + "\s*=")) {
|
||||
$lines[$i] = "$key=$value"
|
||||
$done = $true
|
||||
}
|
||||
}
|
||||
if (-not $done) { $lines += "$key=$value" }
|
||||
Set-Content -Path $SettingsFile -Value $lines -Encoding UTF8
|
||||
}
|
||||
Read-Settings
|
||||
|
||||
# The sizes an eye can be drawn at: the console's largest (1440x1536, what a PlayStation 4 Pro
|
||||
# draws) and larger, all the same shape.
|
||||
$widths = @(1440, 1800, 2160, 2520, 2880, 3240, 3600)
|
||||
function EyeHeight([int]$width) { return [int]([math]::Round(1536.0 * $width / 1440 / 8) * 8) }
|
||||
$caps = @(120, 90, 72, 60, 45, 40, 36, 30)
|
||||
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||
|
||||
# A message box, on top of whatever else is on the desktop (which is what the headset shows).
|
||||
# Returns the name of the button chosen: OK, Yes, No, Cancel.
|
||||
function Show-Box([string]$text, [string]$buttons = "OK", [string]$icon = "Information",
|
||||
[string]$default = "Button1") {
|
||||
$owner = New-Object System.Windows.Forms.Form
|
||||
$owner.TopMost = $true
|
||||
try {
|
||||
return [System.Windows.Forms.MessageBox]::Show($owner, $text, "Astro Bot VR", $buttons,
|
||||
$icon, $default).ToString()
|
||||
} finally { $owner.Dispose() }
|
||||
}
|
||||
|
||||
# Shows one of the launcher's windows and waits for it. In its normal size and in front, also
|
||||
# when the launcher itself was started minimized (Windows would open its first window the same).
|
||||
function Show-Form($form) {
|
||||
$form.Add_Shown({ $this.WindowState = "Normal"; $this.Activate() })
|
||||
return $form.ShowDialog()
|
||||
}
|
||||
|
||||
# --- the game ---------------------------------------------------------------------------------
|
||||
# The game is looked for in the games folder next to this one, as deep as three folders down.
|
||||
# An unpacked game is a folder with eboot.bin in it; a package (.pkg) is unpacked first, once.
|
||||
# game= in the settings names one that is elsewhere (its eboot.bin, its folder or its package),
|
||||
# and when none is found a window asks where it is.
|
||||
$gamesFolder = Join-Path $root "games"
|
||||
$madeFor = "CUSA12392"
|
||||
# The longest path of a file inside that game: the emulator cannot open a file whose whole path
|
||||
# is longer than 259 characters.
|
||||
$longestInside = 126
|
||||
$zeroPasscode = "0" * 32
|
||||
$unpackFolder = ".unpacking"
|
||||
|
||||
function Gigabytes([double]$bytes) { return ("{0:N1} GB" -f ($bytes / 1073741824.0)) }
|
||||
|
||||
# The folders under one, itself first and the nearest first, down to so many levels. What is
|
||||
# inside an unpacked game, or one being unpacked, is left out.
|
||||
function Get-Folders([string]$top, [int]$depth = 3) {
|
||||
$all = New-Object System.Collections.Generic.List[string]
|
||||
if (-not [System.IO.Directory]::Exists($top)) { return $all }
|
||||
$level = @($top)
|
||||
for ($i = 0; $i -le $depth -and $level.Count -gt 0; $i++) {
|
||||
$next = @()
|
||||
foreach ($folder in $level) {
|
||||
$all.Add($folder)
|
||||
if ([System.IO.File]::Exists([System.IO.Path]::Combine($folder, "eboot.bin"))) { continue }
|
||||
try {
|
||||
foreach ($sub in [System.IO.Directory]::GetDirectories($folder)) {
|
||||
if ([System.IO.Path]::GetFileName($sub) -ne $unpackFolder) { $next += $sub }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$level = $next
|
||||
}
|
||||
return $all
|
||||
}
|
||||
|
||||
# What a param.sfo says, by name (TITLE_ID, APP_VER, TITLE...): the texts only.
|
||||
function Read-Sfo([string]$path) {
|
||||
$values = @{}
|
||||
try {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -lt 20 -or [System.BitConverter]::ToUInt32($bytes, 0) -ne 0x46535000) {
|
||||
return $values
|
||||
}
|
||||
$keys = [System.BitConverter]::ToInt32($bytes, 8)
|
||||
$data = [System.BitConverter]::ToInt32($bytes, 12)
|
||||
$count = [System.BitConverter]::ToInt32($bytes, 16)
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$at = 20 + 16 * $i
|
||||
$keyAt = $keys + [System.BitConverter]::ToUInt16($bytes, $at)
|
||||
$format = [System.BitConverter]::ToUInt16($bytes, $at + 2)
|
||||
$length = [System.BitConverter]::ToInt32($bytes, $at + 4)
|
||||
$valueAt = $data + [System.BitConverter]::ToInt32($bytes, $at + 12)
|
||||
$end = [Array]::IndexOf($bytes, [byte]0, $keyAt)
|
||||
$key = [System.Text.Encoding]::ASCII.GetString($bytes, $keyAt, $end - $keyAt)
|
||||
if ($format -eq 0x0204 -and $length -gt 0) {
|
||||
$values[$key] = [System.Text.Encoding]::UTF8.GetString($bytes, $valueAt, $length - 1)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return $values
|
||||
}
|
||||
function Get-GameInfo([string]$eboot) {
|
||||
$folder = [System.IO.Path]::GetDirectoryName($eboot)
|
||||
return Read-Sfo ([System.IO.Path]::Combine($folder, "sce_sys", "param.sfo"))
|
||||
}
|
||||
|
||||
# The unpacked game under a folder: the one this is made for, if there are several.
|
||||
function Find-Game([string]$top) {
|
||||
$first = $null
|
||||
foreach ($folder in (Get-Folders $top)) {
|
||||
$eboot = [System.IO.Path]::Combine($folder, "eboot.bin")
|
||||
if (-not [System.IO.File]::Exists($eboot)) { continue }
|
||||
if ((Get-GameInfo $eboot)["TITLE_ID"] -eq $madeFor) { return $eboot }
|
||||
if ($null -eq $first) { $first = $eboot }
|
||||
}
|
||||
return $first
|
||||
}
|
||||
|
||||
# The package under a folder: the largest, if there are several (a game's is larger than its
|
||||
# updates').
|
||||
function Find-Package([string]$top) {
|
||||
$largest = $null
|
||||
foreach ($folder in (Get-Folders $top)) {
|
||||
try { $files = [System.IO.Directory]::GetFiles($folder, "*.pkg") } catch { continue }
|
||||
foreach ($file in $files) {
|
||||
$info = New-Object System.IO.FileInfo($file)
|
||||
if ($null -eq $largest -or $info.Length -gt $largest.Length) { $largest = $info }
|
||||
}
|
||||
}
|
||||
return $largest
|
||||
}
|
||||
|
||||
# What a package calls its content ("EP9000-CUSA12392_00-..."), or nothing if the file is not
|
||||
# a PlayStation 4 package.
|
||||
function Read-PackageId([string]$path) {
|
||||
try {
|
||||
$head = New-Object byte[] 128
|
||||
$stream = [System.IO.File]::OpenRead($path)
|
||||
try { $read = $stream.Read($head, 0, $head.Length) } finally { $stream.Dispose() }
|
||||
if ($read -lt $head.Length) { return $null }
|
||||
if ($head[0] -ne 0x7F -or $head[1] -ne 0x43 -or $head[2] -ne 0x4E -or $head[3] -ne 0x54) {
|
||||
return $null
|
||||
}
|
||||
return [System.Text.Encoding]::ASCII.GetString($head, 0x40, 36).Trim([char]0)
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
# The window shown while a package is unpacked. True when the unpacking ran to its end, false
|
||||
# when it was cancelled (and stopped).
|
||||
function Show-Unpacking($process, [string]$drive, [double]$freeBefore) {
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Astro Bot VR"
|
||||
$form.ClientSize = New-Object System.Drawing.Size(460, 132)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.MaximizeBox = $false
|
||||
$form.MinimizeBox = $false
|
||||
$form.ControlBox = $false
|
||||
$form.TopMost = $true
|
||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9.5)
|
||||
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = "Unpacking the game. This is done once and takes a minute or a few."
|
||||
$label.SetBounds(16, 14, 428, 22)
|
||||
$form.Controls.Add($label)
|
||||
$bar = New-Object System.Windows.Forms.ProgressBar
|
||||
$bar.Style = "Marquee"
|
||||
$bar.MarqueeAnimationSpeed = 30
|
||||
$bar.SetBounds(16, 44, 428, 18)
|
||||
$form.Controls.Add($bar)
|
||||
$state = New-Object System.Windows.Forms.Label
|
||||
$state.Name = "state"
|
||||
$state.SetBounds(16, 72, 300, 22)
|
||||
$form.Controls.Add($state)
|
||||
$cancel = New-Object System.Windows.Forms.Button
|
||||
$cancel.Name = "cancel"
|
||||
$cancel.Text = "Cancel"
|
||||
$cancel.SetBounds(356, 92, 88, 30)
|
||||
$form.Controls.Add($cancel)
|
||||
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = 500
|
||||
$timer.Add_Tick({
|
||||
if ($process.HasExited) {
|
||||
$timer.Stop()
|
||||
$form.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||
$form.Close()
|
||||
return
|
||||
}
|
||||
if ($freeBefore -ge 0) {
|
||||
try {
|
||||
$free = (New-Object System.IO.DriveInfo($drive)).AvailableFreeSpace
|
||||
$state.Text = (Gigabytes ([math]::Max(0, $freeBefore - $free))) + " unpacked"
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
$cancel.Add_Click({
|
||||
$timer.Stop()
|
||||
try { $process.Kill() } catch {}
|
||||
try { $process.WaitForExit(10000) | Out-Null } catch {}
|
||||
$form.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||||
$form.Close()
|
||||
})
|
||||
$form.Add_Shown({ $timer.Start() })
|
||||
$result = Show-Form $form
|
||||
$timer.Dispose()
|
||||
$form.Dispose()
|
||||
return ($result -eq [System.Windows.Forms.DialogResult]::OK)
|
||||
}
|
||||
|
||||
# Unpacks a package into the games folder, under the game's serial, and returns the eboot.bin
|
||||
# there; nothing if it was not done. PkgTool (of LibOrbisPkg) does the work. It reads packages
|
||||
# that are not encrypted, which is what a dump of a game is; what the PlayStation Store hands
|
||||
# out is encrypted and cannot be unpacked by anything here.
|
||||
function Expand-Package($package) {
|
||||
$contentId = Read-PackageId $package.FullName
|
||||
if ($null -eq $contentId) {
|
||||
[void](Show-Box ($package.FullName + "`n`nis not a PlayStation 4 package.") "OK" "Warning")
|
||||
return $null
|
||||
}
|
||||
$tool = $null
|
||||
foreach ($candidate in @((Join-Path $here "pkgtool\PkgTool.exe"),
|
||||
(Join-Path $root "tools\pkgtool\PkgTool.exe"))) {
|
||||
if ([System.IO.File]::Exists($candidate)) { $tool = $candidate; break }
|
||||
}
|
||||
if ($null -eq $tool) {
|
||||
[void](Show-Box ("The game is here as a package:`n" + $package.FullName + "`n`nbut PkgTool, which unpacks packages, is missing from`n" + (Join-Path $here "pkgtool") + "`n`nUnzip the whole AstroQuest package again.") "OK" "Warning")
|
||||
return $null
|
||||
}
|
||||
$serial = "game"
|
||||
if ($contentId -match "[A-Z]{4}[0-9]{5}") { $serial = $Matches[0] }
|
||||
$target = Join-Path $gamesFolder $serial
|
||||
if ($target.Length + 1 + $longestInside -gt 259) {
|
||||
[void](Show-Box ("The game cannot be unpacked into`n" + $target + "`n`nThat path is too long: some of the game's files would have a path of more than 259 characters, which the emulator cannot open. Move the AstroQuest folder somewhere with a shorter path, for example C:\Games\AstroQuest, and start again.") "OK" "Warning")
|
||||
return $null
|
||||
}
|
||||
# What unpacking takes, by this game's own measure (12.5 GB out of a package of 6.9).
|
||||
$needed = $package.Length * 1.85
|
||||
$drive = [System.IO.Path]::GetPathRoot($target)
|
||||
$free = -1
|
||||
try { $free = (New-Object System.IO.DriveInfo($drive)).AvailableFreeSpace } catch {}
|
||||
if ($free -ge 0 -and $free -lt $needed) {
|
||||
[void](Show-Box ("Unpacking the game takes about " + (Gigabytes $needed) + ", and drive " + $drive + " has " + (Gigabytes $free) + " free.`n`nMake room, or move the AstroQuest folder to a drive that has it.") "OK" "Warning")
|
||||
return $null
|
||||
}
|
||||
$answer = Show-Box ("The game is here as a package:`n`n" + $package.Name + " (" + (Gigabytes $package.Length) + ")`n`nIt has to be unpacked before it can be played. That is done once, takes a minute or a few, and about " + (Gigabytes $needed) + " in`n" + $target + "`n`nUnpack it now?") "YesNo" "Question"
|
||||
if ($answer -ne "Yes") { return $null }
|
||||
|
||||
# Unpacked into a folder of its own first: what is left of an unpacking that did not finish
|
||||
# is never taken for the game.
|
||||
$work = Join-Path $target $unpackFolder
|
||||
try {
|
||||
if ([System.IO.Directory]::Exists($work)) { [System.IO.Directory]::Delete($work, $true) }
|
||||
[void][System.IO.Directory]::CreateDirectory($work)
|
||||
} catch {
|
||||
[void](Show-Box ("Could not write to`n" + $target + "`n`n" + $_.Exception.Message) "OK" "Warning")
|
||||
return $null
|
||||
}
|
||||
$errors = Join-Path $work "pkgtool-errors.txt"
|
||||
$arguments = "pkg_extract --passcode " + $zeroPasscode + " `"" + $package.FullName + "`" `"" + (Join-Path $work "files") + "`""
|
||||
Say ("Unpacking " + $package.FullName)
|
||||
$process = Start-Process -FilePath $tool -ArgumentList $arguments -PassThru -WindowStyle Hidden `
|
||||
-RedirectStandardOutput (Join-Path $work "pkgtool-output.txt") -RedirectStandardError $errors
|
||||
# (Without this the exit code is not to be had later.)
|
||||
$null = $process.Handle
|
||||
$finished = Show-Unpacking $process $drive $free
|
||||
|
||||
$unpacked = Join-Path $work "files\uroot"
|
||||
if (-not [System.IO.Directory]::Exists($unpacked)) { $unpacked = Join-Path $work "files" }
|
||||
$failure = $null
|
||||
if (-not $finished) {
|
||||
$failure = "cancelled"
|
||||
} elseif ($process.ExitCode -ne 0 -or -not [System.IO.File]::Exists((Join-Path $unpacked "eboot.bin"))) {
|
||||
$why = ""
|
||||
try { $why = (@(Get-Content -LiteralPath $errors -ErrorAction Stop | Where-Object { $_.Trim() -ne "" })[0]) } catch {}
|
||||
$failure = "The package could not be unpacked.`n`nOnly a package made from a dump of the game can be: it is not encrypted. A package from the PlayStation Store is, and cannot be used.`n`n" + $package.FullName
|
||||
if ($why) { $failure += "`n`n(PkgTool: " + $why.Trim() + ")" }
|
||||
} else {
|
||||
try {
|
||||
# Into place: over anything of the same name that an earlier attempt left there.
|
||||
foreach ($item in [System.IO.Directory]::GetFileSystemEntries($unpacked)) {
|
||||
$to = Join-Path $target ([System.IO.Path]::GetFileName($item))
|
||||
if ([System.IO.Directory]::Exists($to)) { [System.IO.Directory]::Delete($to, $true) }
|
||||
elseif ([System.IO.File]::Exists($to)) { [System.IO.File]::Delete($to) }
|
||||
[System.IO.Directory]::Move($item, $to)
|
||||
}
|
||||
# What the package keeps outside its file system: the game's own description
|
||||
# (param.sfo, which tells the emulator what game this is), pictures, trophies.
|
||||
# Named ICON0_PNG, PLAYGO_CHUNK_DAT, TROPHY__TROPHY00_TRP... there; icon0.png,
|
||||
# playgo-chunk.dat, trophy/trophy00.trp in the game's sce_sys folder.
|
||||
$leftOut = @("DIGESTS", "ENTRY_KEYS", "IMAGE_KEY", "GENERAL_DIGESTS", "METAS",
|
||||
"ENTRY_NAMES", "LICENSE_DAT", "LICENSE_INFO")
|
||||
foreach ($line in (& $tool pkg_listentries $package.FullName 2>$null)) {
|
||||
if ($line -notmatch '^0x[0-9A-Fa-f]+\s+0x[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\d+)\s+(?:\d+\s+)?([A-Z0-9_]+)\s*$') { continue }
|
||||
$index = $Matches[1]
|
||||
$name = $Matches[2]
|
||||
if ($leftOut -contains $name -or $name.EndsWith("_DDS")) { continue }
|
||||
$file = $name.ToLower().Replace("__", "\")
|
||||
$at = $file.LastIndexOf("_")
|
||||
if ($at -gt 0) { $file = $file.Substring(0, $at) + "." + $file.Substring($at + 1) }
|
||||
if ($file.StartsWith("playgo_")) { $file = "playgo-" + $file.Substring(7) }
|
||||
$file = Join-Path (Join-Path $target "sce_sys") $file
|
||||
[void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($file))
|
||||
& $tool pkg_extractentry --passcode $zeroPasscode $package.FullName $index $file 2>$null | Out-Null
|
||||
}
|
||||
if (-not [System.IO.File]::Exists((Join-Path $target "sce_sys\param.sfo"))) {
|
||||
$failure = "The package was unpacked, but its description of the game (param.sfo) could not be read from it.`n`n" + $package.FullName
|
||||
}
|
||||
} catch {
|
||||
$failure = "The package was unpacked, but the game could not be put in`n" + $target + "`n`n" + $_.Exception.Message
|
||||
}
|
||||
}
|
||||
try { [System.IO.Directory]::Delete($work, $true) } catch {}
|
||||
if ($failure) {
|
||||
if ($failure -ne "cancelled") { [void](Show-Box $failure "OK" "Warning") }
|
||||
return $null
|
||||
}
|
||||
Say ("Unpacked into " + $target)
|
||||
$answer = Show-Box ("The game is unpacked and ready.`n`nThe package is not needed any more:`n" + $package.FullName + "`n`nDelete it, to get " + (Gigabytes $package.Length) + " back? (Keep it if it is your only copy of the game.)") "YesNo" "Question" "Button2"
|
||||
if ($answer -eq "Yes") {
|
||||
try { [System.IO.File]::Delete($package.FullName) } catch {
|
||||
[void](Show-Box ("Could not delete the package:`n" + $_.Exception.Message) "OK" "Warning")
|
||||
}
|
||||
}
|
||||
return (Join-Path $target "eboot.bin")
|
||||
}
|
||||
|
||||
# What a path named in the settings, or picked in the window, gives: the eboot.bin to run, or
|
||||
# nothing.
|
||||
function Use-Path([string]$path) {
|
||||
if ([System.IO.Directory]::Exists($path)) {
|
||||
$eboot = Find-Game $path
|
||||
if ($eboot) { return $eboot }
|
||||
$package = Find-Package $path
|
||||
if ($package) { return Expand-Package $package }
|
||||
return $null
|
||||
}
|
||||
if (-not [System.IO.File]::Exists($path)) { return $null }
|
||||
if ([System.IO.Path]::GetExtension($path) -ieq ".pkg") {
|
||||
return Expand-Package (New-Object System.IO.FileInfo($path))
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
# The window for when the game is nowhere to be found. Returns pick (show where it is), again
|
||||
# (look again) or quit.
|
||||
function Show-NotFound {
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Astro Bot VR"
|
||||
$form.ClientSize = New-Object System.Drawing.Size(600, 250)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.MaximizeBox = $false
|
||||
$form.MinimizeBox = $false
|
||||
$form.TopMost = $true
|
||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9.5)
|
||||
|
||||
$title = New-Object System.Windows.Forms.Label
|
||||
$title.Text = "Where is the game?"
|
||||
$title.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold)
|
||||
$title.SetBounds(16, 14, 568, 26)
|
||||
$form.Controls.Add($title)
|
||||
$text = New-Object System.Windows.Forms.Label
|
||||
$text.Text = "ASTRO BOT Rescue Mission was not found. AstroQuest does not contain the game: it plays your own copy of it.`n`nPut that copy in the games folder - either the game's folder (the one with eboot.bin in it) or its .pkg file - and choose Look again. Or leave it where it is and show where that is."
|
||||
$text.SetBounds(16, 50, 568, 96)
|
||||
$form.Controls.Add($text)
|
||||
$where = New-Object System.Windows.Forms.Label
|
||||
$where.Text = "The games folder: " + $gamesFolder
|
||||
$where.ForeColor = [System.Drawing.SystemColors]::GrayText
|
||||
$where.SetBounds(16, 150, 568, 40)
|
||||
$form.Controls.Add($where)
|
||||
|
||||
$pick = New-Object System.Windows.Forms.Button
|
||||
$pick.Name = "pick"
|
||||
$pick.Text = "Show where it is..."
|
||||
$pick.SetBounds(16, 204, 150, 30)
|
||||
$pick.DialogResult = [System.Windows.Forms.DialogResult]::Yes
|
||||
$form.Controls.Add($pick)
|
||||
$open = New-Object System.Windows.Forms.Button
|
||||
$open.Name = "open"
|
||||
$open.Text = "Open the games folder"
|
||||
$open.SetBounds(174, 204, 170, 30)
|
||||
$open.Add_Click({
|
||||
try {
|
||||
[void][System.IO.Directory]::CreateDirectory($gamesFolder)
|
||||
Start-Process explorer.exe -ArgumentList ("`"" + $gamesFolder + "`"")
|
||||
} catch {}
|
||||
})
|
||||
$form.Controls.Add($open)
|
||||
$again = New-Object System.Windows.Forms.Button
|
||||
$again.Name = "again"
|
||||
$again.Text = "Look again"
|
||||
$again.SetBounds(392, 204, 96, 30)
|
||||
$again.DialogResult = [System.Windows.Forms.DialogResult]::Retry
|
||||
$form.Controls.Add($again)
|
||||
$quit = New-Object System.Windows.Forms.Button
|
||||
$quit.Name = "quit"
|
||||
$quit.Text = "Quit"
|
||||
$quit.SetBounds(496, 204, 88, 30)
|
||||
$quit.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||||
$form.Controls.Add($quit)
|
||||
$form.AcceptButton = $again
|
||||
$form.CancelButton = $quit
|
||||
|
||||
$result = Show-Form $form
|
||||
$form.Dispose()
|
||||
if ($result -eq [System.Windows.Forms.DialogResult]::Yes) { return "pick" }
|
||||
if ($result -eq [System.Windows.Forms.DialogResult]::Retry) { return "again" }
|
||||
return "quit"
|
||||
}
|
||||
|
||||
# The file picker: the game's eboot.bin or its package, wherever they are.
|
||||
function Select-GameFile {
|
||||
$dialog = New-Object System.Windows.Forms.OpenFileDialog
|
||||
$dialog.Title = "Where is ASTRO BOT Rescue Mission? Choose its eboot.bin, or its .pkg file"
|
||||
$dialog.Filter = "The game (eboot.bin, *.pkg)|eboot.bin;*.pkg|All files (*.*)|*.*"
|
||||
$dialog.CheckFileExists = $true
|
||||
if ([System.IO.Directory]::Exists($gamesFolder)) { $dialog.InitialDirectory = $gamesFolder }
|
||||
$owner = New-Object System.Windows.Forms.Form
|
||||
$owner.TopMost = $true
|
||||
try {
|
||||
if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
return $dialog.FileName
|
||||
}
|
||||
return $null
|
||||
} finally {
|
||||
$owner.Dispose()
|
||||
$dialog.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# The eboot.bin to run, or nothing when the player gives up.
|
||||
function Resolve-Game {
|
||||
$named = Setting "game"
|
||||
if ($named -ne "") {
|
||||
$eboot = Use-Path $named
|
||||
if ($eboot) { return $eboot }
|
||||
Say ("The settings name the game at " + $named + ", where it is not: looking in " + $gamesFolder) "Yellow"
|
||||
}
|
||||
while ($true) {
|
||||
$eboot = Find-Game $gamesFolder
|
||||
if ($eboot) { return $eboot }
|
||||
$package = Find-Package $gamesFolder
|
||||
if ($package) {
|
||||
$eboot = Expand-Package $package
|
||||
if ($eboot) { return $eboot }
|
||||
}
|
||||
$choice = Show-NotFound
|
||||
if ($choice -eq "pick") {
|
||||
$file = Select-GameFile
|
||||
if ($file) {
|
||||
$eboot = Use-Path $file
|
||||
if ($eboot) {
|
||||
# In the games folder it is found again by itself; elsewhere it is noted.
|
||||
$inGames = $eboot.StartsWith($gamesFolder + "\", [System.StringComparison]::OrdinalIgnoreCase)
|
||||
if (-not $inGames) { Save-Setting "game" $eboot }
|
||||
return $eboot
|
||||
}
|
||||
}
|
||||
} elseif ($choice -ne "again") {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The Microsoft Visual C++ runtime, which the emulator is built against.
|
||||
function Test-Runtime {
|
||||
$system = [System.Environment]::SystemDirectory
|
||||
foreach ($name in @("vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll",
|
||||
"msvcp140_2.dll", "msvcp140_atomic_wait.dll")) {
|
||||
if (-not [System.IO.File]::Exists((Join-Path $system $name))) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# --- the window -------------------------------------------------------------------------------
|
||||
function Show-Menu {
|
||||
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Astro Bot VR"
|
||||
$form.ClientSize = New-Object System.Drawing.Size(560, 452)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.MaximizeBox = $false
|
||||
$form.MinimizeBox = $false
|
||||
$form.TopMost = $true
|
||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9.5)
|
||||
|
||||
$y = 14
|
||||
$title = New-Object System.Windows.Forms.Label
|
||||
$title.Text = "ASTRO BOT Rescue Mission - PC VR"
|
||||
$title.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold)
|
||||
$title.SetBounds(16, $y, 520, 26)
|
||||
$form.Controls.Add($title)
|
||||
$y += 40
|
||||
|
||||
# Resolution.
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = "Maximum resolution per eye"
|
||||
$label.Font = New-Object System.Drawing.Font("Segoe UI", 9.5, [System.Drawing.FontStyle]::Bold)
|
||||
$label.SetBounds(16, $y, 520, 20)
|
||||
$form.Controls.Add($label)
|
||||
$y += 22
|
||||
$resolution = New-Object System.Windows.Forms.TrackBar
|
||||
$resolution.Minimum = 0
|
||||
$resolution.Maximum = $widths.Count - 1
|
||||
$resolution.TickFrequency = 1
|
||||
$resolution.LargeChange = 1
|
||||
$resolution.SetBounds(12, $y, 530, 40)
|
||||
$current = [int](Setting "resolution" "2880")
|
||||
$index = [array]::IndexOf($widths, $current)
|
||||
if ($index -lt 0) { $index = 4 }
|
||||
$resolution.Value = $index
|
||||
$form.Controls.Add($resolution)
|
||||
$y += 42
|
||||
$resolutionText = New-Object System.Windows.Forms.Label
|
||||
$resolutionText.SetBounds(16, $y, 530, 38)
|
||||
$form.Controls.Add($resolutionText)
|
||||
$update = {
|
||||
$w = $widths[$resolution.Value]
|
||||
$h = EyeHeight $w
|
||||
$times = ($w * $h) / (1440.0 * 1536.0)
|
||||
$what = if ($w -eq 1440) { "the console's own, as a PlayStation 4 Pro draws it" } else { "{0:N2} times the pixels of the console" -f $times }
|
||||
$resolutionText.Text = "$w x $h pixels an eye: $what. The game draws smaller by itself when the graphics card cannot keep up."
|
||||
}
|
||||
$resolution.Add_ValueChanged($update)
|
||||
& $update
|
||||
$y += 46
|
||||
|
||||
# Frame rate.
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = "Maximum framerate (FPS)"
|
||||
$label.Font = New-Object System.Drawing.Font("Segoe UI", 9.5, [System.Drawing.FontStyle]::Bold)
|
||||
$label.SetBounds(16, $y, 520, 20)
|
||||
$form.Controls.Add($label)
|
||||
$y += 24
|
||||
$fps = New-Object System.Windows.Forms.ComboBox
|
||||
$fps.DropDownStyle = "DropDownList"
|
||||
foreach ($cap in $caps) {
|
||||
$text = "$cap"
|
||||
if ($cap -eq 60) { $text = "60 (the console's own)" }
|
||||
[void]$fps.Items.Add($text)
|
||||
}
|
||||
$fps.SetBounds(16, $y, 200, 26)
|
||||
$index = [array]::IndexOf($caps, [int](Setting "fps" "60"))
|
||||
if ($index -lt 0) { $index = 3 }
|
||||
$fps.SelectedIndex = $index
|
||||
$form.Controls.Add($fps)
|
||||
$y += 32
|
||||
$fpsText = New-Object System.Windows.Forms.Label
|
||||
$fpsText.Text = "A frame lasts a whole number of the headset's refreshes, so the headset's refresh rate decides what is possible: at 120 Hz 120, 60, 40 or 30 frames a second, at 90 Hz 90, 45 or 30, at 72 Hz 72 or 36. Virtual Desktop sets the refresh rate (Settings > Streaming > Frame rate): choose 120 for 60 frames a second."
|
||||
$fpsText.SetBounds(16, $y, 530, 84)
|
||||
$form.Controls.Add($fpsText)
|
||||
$y += 88
|
||||
|
||||
# Field of view.
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = "Field of view"
|
||||
$label.Font = New-Object System.Drawing.Font("Segoe UI", 9.5, [System.Drawing.FontStyle]::Bold)
|
||||
$label.SetBounds(16, $y, 520, 20)
|
||||
$form.Controls.Add($label)
|
||||
$y += 22
|
||||
$fov = New-Object System.Windows.Forms.TrackBar
|
||||
$fov.Minimum = 14
|
||||
$fov.Maximum = 20
|
||||
$fov.TickFrequency = 1
|
||||
$fov.LargeChange = 1
|
||||
$fov.SetBounds(12, $y, 300, 40)
|
||||
$fov.Value = [math]::Max(14, [math]::Min(20, [int]([int](Setting "fov" "100") / 5)))
|
||||
$form.Controls.Add($fov)
|
||||
$fovText = New-Object System.Windows.Forms.Label
|
||||
$fovText.SetBounds(316, $y + 4, 230, 40)
|
||||
$form.Controls.Add($fovText)
|
||||
$ofPsvr = (Setting "fov_of" "headset") -eq "psvr"
|
||||
$updateFov = {
|
||||
$percent = $fov.Value * 5
|
||||
if ($percent -eq 100) {
|
||||
$fovText.Text = $(if ($ofPsvr) { "100%: PlayStation VR's own" } else { "100%: all that the headset shows" })
|
||||
} else {
|
||||
$fovText.Text = "$percent% of it: sharper, with a dark border"
|
||||
}
|
||||
}
|
||||
$fov.Add_ValueChanged($updateFov)
|
||||
& $updateFov
|
||||
$y += 46
|
||||
|
||||
$again = New-Object System.Windows.Forms.CheckBox
|
||||
$again.Text = "Show this window at every start"
|
||||
$again.Checked = (Setting "menu" "1") -ne "0"
|
||||
if ($ForceMenu) {
|
||||
$again.Checked = $true
|
||||
$again.Enabled = $false
|
||||
}
|
||||
$again.SetBounds(16, $y, 300, 24)
|
||||
$form.Controls.Add($again)
|
||||
|
||||
$play = New-Object System.Windows.Forms.Button
|
||||
$play.Text = "Play"
|
||||
$play.SetBounds(360, $y - 2, 88, 30)
|
||||
$play.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||
$form.Controls.Add($play)
|
||||
$form.AcceptButton = $play
|
||||
$quit = New-Object System.Windows.Forms.Button
|
||||
$quit.Text = "Quit"
|
||||
$quit.SetBounds(456, $y - 2, 88, 30)
|
||||
$quit.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||||
$form.Controls.Add($quit)
|
||||
$form.CancelButton = $quit
|
||||
|
||||
$result = Show-Form $form
|
||||
if ($result -ne [System.Windows.Forms.DialogResult]::OK) { return $false }
|
||||
Save-Setting "resolution" ($widths[$resolution.Value])
|
||||
Save-Setting "fps" ($caps[$fps.SelectedIndex])
|
||||
Save-Setting "fov" ($fov.Value * 5)
|
||||
Save-Setting "menu" ($(if ($again.Checked) { "1" } else { "0" }))
|
||||
Read-Settings
|
||||
return $true
|
||||
}
|
||||
|
||||
$emulator = Join-Path $here "shadps4.exe"
|
||||
if (-not [System.IO.File]::Exists($emulator)) {
|
||||
[void](Show-Box ("The emulator, shadps4.exe, is missing from`n" + $here + "`n`nUnzip the whole AstroQuest package again. (Built from the source: run tools/make-pc-vr.sh.)") "OK" "Error")
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Runtime)) {
|
||||
$answer = Show-Box "The emulator needs the Microsoft Visual C++ runtime, which is not installed on this PC.`n`nDownload its installer from Microsoft now? Run it, then start Play Astro Bot VR again." "YesNo" "Warning"
|
||||
if ($answer -eq "Yes") { Start-Process "https://aka.ms/vs/17/release/vc_redist.x64.exe" }
|
||||
exit 1
|
||||
}
|
||||
|
||||
$game = Resolve-Game
|
||||
if (-not $game) { exit 0 }
|
||||
Say ("The game: " + $game)
|
||||
$info = Get-GameInfo $game
|
||||
if ($info.Count -eq 0) {
|
||||
Say "sce_sys\param.sfo is missing next to it: this is not a complete copy of the game, and the emulator may not know it." "Yellow"
|
||||
} elseif ($info["TITLE_ID"] -ne $madeFor -or $info["APP_VER"] -ne "01.00") {
|
||||
Say ("This is " + $info["TITLE"] + ", " + $info["TITLE_ID"] + " version " + $info["APP_VER"] + ". AstroQuest is made for the European release, " + $madeFor + " version 01.00: with another, its fixes for the game's speed and picture do not apply, and it may not run.") "Yellow"
|
||||
} elseif ([System.IO.Path]::GetDirectoryName($game).Length + 1 + $longestInside -gt 259) {
|
||||
[void](Show-Box ("The game is in`n" + [System.IO.Path]::GetDirectoryName($game) + "`n`nThat path is too long: some of the game's files have a path of more than 259 characters there, which the emulator cannot open, and the game would stop when it needs them. Move the folder somewhere with a shorter path, for example C:\Games\AstroQuest, and start again.") "OK" "Warning")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $NoMenu -and ($ForceMenu -or (Setting "menu" "1") -ne "0")) {
|
||||
if (-not (Show-Menu)) { exit 0 }
|
||||
}
|
||||
|
||||
# What the settings mean to the emulator.
|
||||
# resolution: the width of an eye (1440 the console's; larger ones are the game's sizes grown,
|
||||
# with the memory that takes). game: the console's sizes, chosen by the game itself.
|
||||
$resolution = Setting "resolution" "2880"
|
||||
$dynamic = (Setting "dynamic" "1") -ne "0"
|
||||
if ($resolution -eq "game") {
|
||||
$env:SHADPS4_TITLE_RESOLUTION = "title"
|
||||
} else {
|
||||
$width = 0
|
||||
if (-not [int]::TryParse($resolution, [ref]$width)) { $width = 2880 }
|
||||
# (The console's other sizes, 816 to 1200, as they were offered before.)
|
||||
$smaller = @{ 816 = "3"; 960 = "4"; 1200 = "5" }
|
||||
if ($smaller.ContainsKey($width)) {
|
||||
$env:SHADPS4_TITLE_RESOLUTION = $smaller[$width]
|
||||
} else {
|
||||
$width = [math]::Max(1440, [math]::Min(4320, [int]([math]::Round($width / 8) * 8)))
|
||||
if ($width -gt 1440) { $env:SHADPS4_TITLE_EYE_WIDTH = "$width" }
|
||||
# Left to choose, the emulator draws smaller where the graphics card falls behind.
|
||||
if (-not $dynamic) { $env:SHADPS4_TITLE_RESOLUTION = "6" }
|
||||
}
|
||||
}
|
||||
$env:SHADPS4_VR_SHARPEN = Setting "sharpen" "0.3"
|
||||
if ((Setting "msaa") -ne "") { $env:SHADPS4_MAX_MSAA = Setting "msaa" }
|
||||
if ((Setting "antialias" "1") -eq "0") { $env:SHADPS4_RESOLVE_AA = "0" }
|
||||
if ((Setting "hands" "1") -eq "0") { $env:SHADPS4_XR_HANDS = "0" }
|
||||
if ((Setting "predict_ms") -ne "") { $env:SHADPS4_XR_PREDICT_MS = Setting "predict_ms" }
|
||||
if ((Setting "stick_touchpad" "1") -eq "0") { $env:SHADPS4_STICK_TOUCHPAD = "0" }
|
||||
if ((Setting "surround" "1") -eq "0") { $env:SHADPS4_VIRTUAL_SURROUND = "0" }
|
||||
if ((Setting "real_time" "1") -eq "0") { $env:SHADPS4_TITLE_TIMESTEP = "0" }
|
||||
$fovSetting = Setting "fov" "100"
|
||||
if ($fovSetting -ne "100") { $env:SHADPS4_VR_FOV = $fovSetting }
|
||||
# fov_of: what fov is a percent of. headset: what the headset being worn shows, all of it at 100
|
||||
# (the emulator asks the headset as it starts). psvr: a PlayStation VR's, as the game was made.
|
||||
if ((Setting "fov_of" "headset") -ne "psvr") { $env:SHADPS4_VR_FOV_OF = "headset" }
|
||||
# fps: the most frames a second. (pace, the older way to say it: refreshes of the headset a
|
||||
# frame is given, 1 or more.)
|
||||
$env:SHADPS4_VR_FPS_CAP = Setting "fps" "60"
|
||||
$pace = Setting "pace" ""
|
||||
if ($pace -eq "1") { $env:SHADPS4_VR_FASTEST_PACE = "1"; $env:SHADPS4_VR_FPS_CAP = "" } elseif ($pace -ne "" -and $pace -ne "2") { $env:SHADPS4_VR_PACE = $pace }
|
||||
if ((Setting "headset" "1") -eq "0") { $env:SHADPS4_OPENXR = "0" }
|
||||
if ((Setting "pause" "1") -eq "0") { $env:SHADPS4_XR_PAUSE = "0" }
|
||||
if ((Setting "controllers" "1") -eq "0") { $env:SHADPS4_XR_CONTROLLERS = "0" }
|
||||
if ((Setting "controller_hand" "right") -eq "left") { $env:SHADPS4_XR_PAD_HAND = "left" }
|
||||
$env:SHADPS4_XR_WAIT = Setting "wait" "60"
|
||||
foreach ($pair in $extraEnv) {
|
||||
$at = $pair.IndexOf("=")
|
||||
if ($at -ge 1) { Set-Item -Path ("Env:" + $pair.Substring(0, $at)) -Value $pair.Substring($at + 1) }
|
||||
}
|
||||
|
||||
# --- what is there ----------------------------------------------------------------------------
|
||||
Say "ASTRO BOT Rescue Mission - PC VR" "Cyan"
|
||||
if ($env:SHADPS4_TITLE_EYE_WIDTH) {
|
||||
Say ("Each eye up to " + $env:SHADPS4_TITLE_EYE_WIDTH + " x " + (EyeHeight ([int]$env:SHADPS4_TITLE_EYE_WIDTH)) + ", at most " + $env:SHADPS4_VR_FPS_CAP + " frames a second.")
|
||||
}
|
||||
$runtime = ""
|
||||
if ($env:XR_RUNTIME_JSON) {
|
||||
$runtime = $env:XR_RUNTIME_JSON
|
||||
} else {
|
||||
try { $runtime = (Get-ItemProperty 'HKLM:\SOFTWARE\Khronos\OpenXR\1' -ErrorAction Stop).ActiveRuntime } catch {}
|
||||
}
|
||||
if ($runtime -eq "") {
|
||||
Say "No OpenXR runtime is set up on this PC: the game will only show on the monitor." "Yellow"
|
||||
Say "Virtual Desktop Streamer installs one (Options > OpenXR Runtime: VDXR)."
|
||||
} else {
|
||||
Say "OpenXR runtime: $runtime"
|
||||
if ($runtime -match "virtualdesktop") {
|
||||
$streamer = Get-Process "VirtualDesktop.Streamer" -ErrorAction SilentlyContinue
|
||||
if (-not $streamer) {
|
||||
$exe = Join-Path (Split-Path -Parent (Split-Path -Parent $runtime)) "VirtualDesktop.Streamer.exe"
|
||||
if (Test-Path $exe) {
|
||||
Say "Starting Virtual Desktop Streamer..."
|
||||
Start-Process $exe
|
||||
} else {
|
||||
Say "Virtual Desktop Streamer is not running: start it, then connect from the headset." "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Say ""
|
||||
Say "In the headset: connect Virtual Desktop to this PC. The game moves into the headset by itself."
|
||||
if ($env:SHADPS4_OPENXR -ne "0" -and [int]$env:SHADPS4_XR_WAIT -gt 0) {
|
||||
Say ("The game waits up to " + $env:SHADPS4_XR_WAIT + " seconds for the headset before it starts on the monitor.")
|
||||
}
|
||||
Say "The DualSense: connect it to THIS PC (USB cable, or Bluetooth paired with the PC). Paired with"
|
||||
Say "the headset, it reaches the PC through Virtual Desktop without motion sensors or touchpad."
|
||||
Say "Where it is in the game comes from your hands: hand tracking on in the headset, and in"
|
||||
Say "Virtual Desktop's settings hand tracking forwarded to the PC."
|
||||
Say "Hold OPTIONS for a second (or press the PS button) to reset the view."
|
||||
Say "No gamepad: the headset's own controllers play (A jump, B punch, right stick = touchpad,"
|
||||
Say "press both sticks in to reset the view)."
|
||||
Say "Close the game's window to quit."
|
||||
Say ""
|
||||
# The emulator asks Windows for about 14 GB at once (the console's memory, and what the larger
|
||||
# pictures take). Where Windows cannot promise that much, the emulator stops as it starts.
|
||||
$memoryShort = $false
|
||||
try {
|
||||
$spare = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).FreeVirtualMemory * 1024.0
|
||||
if ($spare -lt 16GB) {
|
||||
$memoryShort = $true
|
||||
Say ("Windows has " + (Gigabytes $spare) + " of memory left to hand out, and the emulator asks for about 14 GB: if the game does not start, close other programs and start again.") "Yellow"
|
||||
Say ""
|
||||
}
|
||||
} catch {}
|
||||
|
||||
# --- run --------------------------------------------------------------------------------------
|
||||
$logDir = Join-Path $here "user\log"
|
||||
$log = Join-Path $logDir "shad_log.txt"
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
if (Test-Path $log) { Copy-Item $log (Join-Path $logDir "shad_log.prev.txt") -Force }
|
||||
|
||||
# (In this console, with what it prints kept out of the way: a window style given here would
|
||||
# also be the game window's.)
|
||||
$startedAt = Get-Date
|
||||
$process = Start-Process -FilePath $emulator -ArgumentList @("-g", "`"$game`"") -WorkingDirectory $here `
|
||||
-PassThru -NoNewWindow -RedirectStandardOutput (Join-Path $logDir "console.txt") `
|
||||
-RedirectStandardError (Join-Path $logDir "console-errors.txt")
|
||||
# (Without this the exit code is not to be had later.)
|
||||
$null = $process.Handle
|
||||
$position = 0
|
||||
$shown = @{}
|
||||
function Show-Log {
|
||||
if (-not (Test-Path $log)) { return }
|
||||
try {
|
||||
$stream = [System.IO.File]::Open($log, 'Open', 'Read', 'ReadWrite')
|
||||
} catch { return }
|
||||
try {
|
||||
if ($stream.Length -lt $script:position) { $script:position = 0 }
|
||||
[void]$stream.Seek($script:position, 'Begin')
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
while ($true) {
|
||||
$line = $reader.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ($line -match '^\[Core\.Vr\] <(Info|Warning)> \([^)]*\) \S+ (?:\w+: )?(.*)$') {
|
||||
$warning = $Matches[1] -eq "Warning"
|
||||
$text = $Matches[2]
|
||||
# The lines that repeat every few seconds only once in a while.
|
||||
if ($text -match '^(The title (has|now takes) the player|Hands:|Virtual headset connected)') { continue }
|
||||
if ($text -match '^Headset: the title delivered') {
|
||||
$script:reports++
|
||||
if (($script:reports % 6) -ne 1) { continue }
|
||||
}
|
||||
if ($text -match '^Controllers: standing in') {
|
||||
if (($script:reports % 6) -ne 1) { continue }
|
||||
}
|
||||
if ($warning) { Say (" " + $text) "Yellow" } else { Say (" " + $text) }
|
||||
} elseif ($line -match '^\[Input\] <Info> \([^)]*\) \S+ (?:\w+: )?(Controller .*)$') {
|
||||
Say (" " + $Matches[1])
|
||||
} elseif ($line -match '^\[Core\] <Info> \([^)]*\) \S+ (?:\w+: )?(The title draws at up to .*|The scene is drawn at .*|Frames are given .*)$') {
|
||||
Say (" " + $Matches[1])
|
||||
} elseif ($line -match '<Critical>.*?: (.*)$') {
|
||||
$text = $Matches[1]
|
||||
if (-not $shown.ContainsKey($text)) { $shown[$text] = 1; Say (" ! " + $text) "Red" }
|
||||
}
|
||||
}
|
||||
$script:position = $stream.Position
|
||||
} finally { $stream.Dispose() }
|
||||
}
|
||||
$reports = 0
|
||||
while (-not $process.HasExited) {
|
||||
Start-Sleep -Milliseconds 700
|
||||
Show-Log
|
||||
}
|
||||
Show-Log
|
||||
Say ""
|
||||
if ($null -ne $process.ExitCode -and $process.ExitCode -ne 0) {
|
||||
Say ("The emulator ended with code " + $process.ExitCode + ". Its log is $log") "Yellow"
|
||||
if ($memoryShort -and ((Get-Date) - $startedAt).TotalSeconds -lt 30) {
|
||||
Say "It stopped as it started, and Windows was short of memory then (see above): close other programs and start again."
|
||||
}
|
||||
Read-Host "Press Enter to close"
|
||||
} else {
|
||||
Say "The game was closed."
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
@@ -1,102 +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, fps 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 or 3600 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
|
||||
|
||||
# The most frames a second: 120, 90, 72, 60, 45, 40, 36 or 30. A frame lasts a whole number of
|
||||
# the headset's refreshes, so the refresh rate set in Virtual Desktop (Streaming > Frame rate)
|
||||
# decides what is possible: at 120 Hz 120, 60, 40 or 30; at 90 Hz 90, 45 or 30; at 72 Hz 72 or
|
||||
# 36. 60 is the console's own. Above 60 the PC has to keep up (this one manages about 100), and
|
||||
# the few things the game counts in frames (some animated signs) run faster.
|
||||
#fps=60
|
||||
|
||||
# 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
|
||||
|
||||
# 0: the headset's own controllers never stand in for a gamepad. (They only do while no gamepad
|
||||
# is connected to the PC.)
|
||||
#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
|
||||
|
||||
# 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
|
||||
resolution=3600
|
||||
fps=72
|
||||
fov=100
|
||||
menu=1
|
||||
@@ -1,96 +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="13"
|
||||
android:versionName="0.13"
|
||||
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. Declaring hand tracking tells Horizon OS that Touch
|
||||
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,167 +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]
|
||||
#
|
||||
# --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.
|
||||
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=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--driver) driver=$2; shift 2 ;;
|
||||
--trade-cpu-for-gpu) trade=1; 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")" \
|
||||
"$(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
|
||||
"$BUILD_TOOLS/aapt2.exe" compile --dir "$(cygpath -m "$host/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 \
|
||||
--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" ]]; 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 "$out/astro-vr-host.apk")" "$(cygpath -m "$out/aligned.apk")"
|
||||
|
||||
ls -la "$out/astro-vr-host.apk"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,147 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "vr_protocol.h"
|
||||
|
||||
/// 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;
|
||||
};
|
||||
|
||||
/// 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{};
|
||||
};
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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;
|
||||
|
||||
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,256 +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;
|
||||
in vec2 uv;
|
||||
out vec4 color;
|
||||
void main() {
|
||||
vec3 c = texture(frame, uv).rgb;
|
||||
if (swap_red_blue != 0) {
|
||||
c = c.bgr;
|
||||
}
|
||||
// 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");
|
||||
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) 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);
|
||||
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,80 +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.
|
||||
void Draw(GLuint texture, uint32_t width, uint32_t height, bool swap_red_blue,
|
||||
bool srgb_target) 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};
|
||||
bool write_control{};
|
||||
};
|
||||
@@ -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,268 +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 "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{};
|
||||
std::mutex pad_mutex;
|
||||
PadState pad;
|
||||
};
|
||||
|
||||
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) {
|
||||
if (g_app) {
|
||||
return;
|
||||
}
|
||||
g_app = std::make_unique<App>();
|
||||
env->GetJavaVM(&g_app->vm);
|
||||
g_app->activity = env->NewGlobalRef(activity);
|
||||
|
||||
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;
|
||||
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.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 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;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeInput(
|
||||
JNIEnv*, jobject, jint buttons, jint left_x, jint left_y, jint right_x, jint right_y,
|
||||
jint left_trigger, jint right_trigger, jboolean touch_down, jint touch_x, jint touch_y) {
|
||||
if (!g_app) {
|
||||
return;
|
||||
}
|
||||
std::scoped_lock lock{g_app->pad_mutex};
|
||||
PadState& pad = g_app->pad;
|
||||
pad.buttons = static_cast<uint32_t>(buttons);
|
||||
pad.left_x = static_cast<uint8_t>(left_x);
|
||||
pad.left_y = static_cast<uint8_t>(left_y);
|
||||
pad.right_x = static_cast<uint8_t>(right_x);
|
||||
pad.right_y = static_cast<uint8_t>(right_y);
|
||||
pad.left_trigger = static_cast<uint8_t>(left_trigger);
|
||||
pad.right_trigger = static_cast<uint8_t>(right_trigger);
|
||||
pad.touch_down = touch_down == JNI_TRUE;
|
||||
pad.touch_x = static_cast<uint16_t>(touch_x);
|
||||
pad.touch_y = static_cast<uint16_t>(touch_y);
|
||||
g_app->core.SetPad(pad);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
std::scoped_lock lock{g_app->pad_mutex};
|
||||
PadState& pad = g_app->pad;
|
||||
pad.has_motion = true;
|
||||
pad.gyro[0] = gx;
|
||||
pad.gyro[1] = gy;
|
||||
pad.gyro[2] = gz;
|
||||
pad.accel[0] = ax;
|
||||
pad.accel[1] = ay;
|
||||
pad.accel[2] = az;
|
||||
g_app->core.SetPad(pad);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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) |
|
||||
(refresh_rate << 8);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -1,550 +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 "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;
|
||||
for (const auto& [name, value] : launch.extra_env) {
|
||||
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());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
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 + 10.0f;
|
||||
}
|
||||
}
|
||||
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,88 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
class CoreProcess;
|
||||
|
||||
/// 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};
|
||||
};
|
||||
|
||||
/// 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{};
|
||||
std::atomic<float> refresh_rate{};
|
||||
};
|
||||
|
||||
/// Owns the OpenXR session: feeds the head pose to the core and shows the stereo frames it
|
||||
/// delivers. Blocks until `quit` is set or the system ends the session.
|
||||
void RunXrHost(JavaVM* vm, jobject activity, CoreProcess& core, StatusImage& status,
|
||||
const XrHostOptions& options, XrHostStatus& host_status, std::atomic<bool>& quit,
|
||||
std::atomic<uint32_t>& recenter_requests);
|
||||
@@ -1,221 +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 "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;
|
||||
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, 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,295 +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;
|
||||
if (reset == 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 (phase == 10) {
|
||||
activity[0].dispatchGenericMotionEvent(stickEvent(0.5f, -0.25f));
|
||||
} 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 left stick is moved. */
|
||||
private static MotionEvent stickEvent(float x, float y) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ option(ENABLE_SYSTEM_LIBRARIES "Enable building against system libraries" OFF)
|
||||
option(ENABLE_FEX_GUEST_CPU "Build the native ARM64 FEXCore guest CPU backend" OFF)
|
||||
# The headset of the machine the emulator runs on, through that machine's OpenXR runtime
|
||||
# (Virtual Desktop, SteamVR, ...): where the frames of an emulated PlayStation VR are shown.
|
||||
cmake_dependent_option(ENABLE_OPENXR "Show the emulated headset's frames in a headset of this machine" ON "WIN32;NOT ENABLE_BACHATA_RUNTIME" OFF)
|
||||
cmake_dependent_option(ENABLE_OPENXR "Show the emulated headset's frames in a headset of this machine" ON "WIN32 OR CMAKE_SYSTEM_NAME STREQUAL Linux;NOT ENABLE_BACHATA_RUNTIME" OFF)
|
||||
set(FEXCORE_GUEST_CPU_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime/sources/fex" CACHE PATH
|
||||
"Checked-out FEX source directory for the FEXCore guest CPU backend")
|
||||
set(FEXCORE_GUEST_CPU_BUILD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime/build/fexcore-smoke-build" CACHE PATH
|
||||
@@ -1017,6 +1017,7 @@ set(CORE src/core/aerolib/stubs.cpp
|
||||
src/core/guest_cpu/guest_watchdog.h
|
||||
src/core/known_title.cpp
|
||||
src/core/known_title.h
|
||||
src/core/known_title_builds.h
|
||||
src/core/linker.cpp
|
||||
src/core/linker.h
|
||||
src/core/memory.cpp
|
||||
@@ -1295,6 +1296,7 @@ set(IMGUI src/imgui/imgui_config.h
|
||||
|
||||
set(INPUT src/input/controller.cpp
|
||||
src/input/controller.h
|
||||
src/input/stick_finger.h
|
||||
src/input/input_handler.cpp
|
||||
src/input/input_handler.h
|
||||
src/input/input_mouse.cpp
|
||||
|
||||
@@ -18,6 +18,12 @@ FEX_HLE_BRIDGE_SOURCE="${PROJECT_ROOT}/src/core/guest_cpu/fex_hle_bridge.cpp"
|
||||
GUEST_HARNESS_SOURCE="${PROJECT_ROOT}/runtime/probes/fexcore-guest-harness.cpp"
|
||||
GUEST_HARNESS_VERIFIER="${PROJECT_ROOT}/runtime/tests/verify-fexcore-guest-harness-build.mjs"
|
||||
PATCH_APPLIED=0
|
||||
# Native ARM64 builds use the SDK's libraries directly; cross builds use the Debian sysroot.
|
||||
find_root_args=()
|
||||
if [[ $(uname -m) != aarch64 ]]; then
|
||||
find_root_args=(-DCMAKE_FIND_ROOT_PATH=/usr/aarch64-linux-gnu)
|
||||
fi
|
||||
strip_tool=$(command -v aarch64-linux-gnu-strip || command -v strip)
|
||||
|
||||
cleanup_patch() {
|
||||
local status=$?
|
||||
@@ -83,7 +89,7 @@ cmake -S "${FEX_SOURCE}" -B "${BUILD_DIR}" -G Ninja \
|
||||
-DCMAKE_CXX_COMPILER=clang++ \
|
||||
-DCMAKE_C_COMPILER_TARGET=aarch64-linux-gnu \
|
||||
-DCMAKE_CXX_COMPILER_TARGET=aarch64-linux-gnu \
|
||||
-DCMAKE_FIND_ROOT_PATH=/usr/aarch64-linux-gnu \
|
||||
"${find_root_args[@]}" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DTUNE_CPU=none \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
@@ -115,7 +121,7 @@ cmake --build "${BUILD_DIR}" --target fexcore-smoke fexcore-guest-harness --para
|
||||
rm -rf "${STAGE_DIR}"
|
||||
mkdir -p "${STAGE_DIR}"
|
||||
cmake --install "${BUILD_DIR}" --prefix "${STAGE_DIR}"
|
||||
aarch64-linux-gnu-strip "${STAGE_DIR}/bin/fexcore-smoke" "${STAGE_DIR}/bin/fexcore-guest-harness"
|
||||
"${strip_tool}" "${STAGE_DIR}/bin/fexcore-smoke" "${STAGE_DIR}/bin/fexcore-guest-harness"
|
||||
node "${VERIFIER}" "${STAGE_DIR}/bin/fexcore-smoke"
|
||||
node "${GUEST_HARNESS_VERIFIER}" "${STAGE_DIR}/bin/fexcore-guest-harness"
|
||||
/bin/mkdir -p "${BUILD_DIR}"
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace Common {
|
||||
|
||||
/// The console's languages, by the numbers a title is told (sceSystemServiceParamGetInt with
|
||||
/// ORBIS_SYSTEM_SERVICE_PARAM_ID_LANG).
|
||||
inline constexpr std::array<const char*, 30> ConsoleLanguageNames{
|
||||
"Japanese",
|
||||
"English (United States)",
|
||||
"French",
|
||||
"Spanish",
|
||||
"German",
|
||||
"Italian",
|
||||
"Dutch",
|
||||
"Portuguese (Portugal)",
|
||||
"Russian",
|
||||
"Korean",
|
||||
"Chinese (traditional)",
|
||||
"Chinese (simplified)",
|
||||
"Finnish",
|
||||
"Swedish",
|
||||
"Danish",
|
||||
"Norwegian",
|
||||
"Polish",
|
||||
"Portuguese (Brazil)",
|
||||
"English (United Kingdom)",
|
||||
"Turkish",
|
||||
"Spanish (Latin America)",
|
||||
"Arabic",
|
||||
"French (Canada)",
|
||||
"Czech",
|
||||
"Hungarian",
|
||||
"Greek",
|
||||
"Romanian",
|
||||
"Thai",
|
||||
"Vietnamese",
|
||||
"Indonesian",
|
||||
};
|
||||
|
||||
inline const char* ConsoleLanguageName(int language) {
|
||||
return language >= 0 && language < static_cast<int>(ConsoleLanguageNames.size())
|
||||
? ConsoleLanguageNames[language]
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
/// The console's number for a language named the way the systems around the emulator name
|
||||
/// theirs: a language tag ("fr-FR", "pt_BR", "zh-Hans-CN", "es-419"), or the number itself.
|
||||
/// Nothing for what the console has no language for.
|
||||
inline std::optional<int> ConsoleLanguageFromTag(std::string_view text) {
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) {
|
||||
text.remove_prefix(1);
|
||||
}
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) {
|
||||
text.remove_suffix(1);
|
||||
}
|
||||
if (text.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
bool digits = true;
|
||||
for (const char c : text) {
|
||||
digits = digits && std::isdigit(static_cast<unsigned char>(c));
|
||||
}
|
||||
if (digits) {
|
||||
if (text.size() > 2) {
|
||||
return std::nullopt;
|
||||
}
|
||||
int number = 0;
|
||||
for (const char c : text) {
|
||||
number = number * 10 + (c - '0');
|
||||
}
|
||||
return number < static_cast<int>(ConsoleLanguageNames.size()) ? std::optional{number}
|
||||
: std::nullopt;
|
||||
}
|
||||
|
||||
// The language, and what follows it (script, region; "fr_FR.UTF-8" has more).
|
||||
std::vector<std::string> parts{{}};
|
||||
for (const char c : text) {
|
||||
if (c == '.' || c == '@') {
|
||||
break;
|
||||
}
|
||||
if (c == '-' || c == '_') {
|
||||
parts.emplace_back();
|
||||
} else {
|
||||
parts.back().push_back(
|
||||
static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
}
|
||||
const std::string& language = parts.front();
|
||||
const auto has = [&](std::string_view part) {
|
||||
for (size_t i = 1; i < parts.size(); ++i) {
|
||||
if (parts[i] == part) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const bool regional = parts.size() > 1;
|
||||
|
||||
if (language == "en") {
|
||||
// British spelling everywhere but in North America.
|
||||
return !regional || has("us") || has("ca") || has("ph") ? 1 : 18;
|
||||
}
|
||||
if (language == "fr") {
|
||||
return has("ca") ? 22 : 2;
|
||||
}
|
||||
if (language == "es") {
|
||||
return !regional || has("es") ? 3 : 20;
|
||||
}
|
||||
if (language == "pt") {
|
||||
return has("br") ? 17 : 7;
|
||||
}
|
||||
if (language == "zh") {
|
||||
return has("hant") || has("tw") || has("hk") || has("mo") ? 10 : 11;
|
||||
}
|
||||
struct Entry {
|
||||
std::string_view language;
|
||||
int number;
|
||||
};
|
||||
static constexpr Entry Others[] = {
|
||||
{"ja", 0}, {"de", 4}, {"it", 5}, {"nl", 6}, {"ru", 8}, {"ko", 9}, {"fi", 12},
|
||||
{"sv", 13}, {"da", 14}, {"nb", 15}, {"no", 15}, {"nn", 15}, {"pl", 16}, {"tr", 19},
|
||||
{"ar", 21}, {"cs", 23}, {"hu", 24}, {"el", 25}, {"ro", 26}, {"th", 27}, {"vi", 28},
|
||||
{"id", 29}, {"in", 29},
|
||||
};
|
||||
for (const Entry& entry : Others) {
|
||||
if (language == entry.language) {
|
||||
return entry.number;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace Common
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <queue>
|
||||
#ifdef _WIN32
|
||||
#include <immintrin.h>
|
||||
#include "common/windows_sleep_timer.h"
|
||||
#endif
|
||||
namespace Common {
|
||||
// Adapted from elliotttate/AstroQuest's precise guest HR timer scheduler.
|
||||
// Callback owners supply lifetime/cancellation guards. Callbacks run without the queue lock.
|
||||
class PreciseTimerScheduler {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
explicit PreciseTimerScheduler(
|
||||
std::chrono::microseconds budget = std::chrono::microseconds{300})
|
||||
: spin(budget) {
|
||||
#ifdef _WIN32
|
||||
wake = CreateEventW(nullptr, FALSE, FALSE, nullptr);
|
||||
available = wake && timer.IsHighResolution();
|
||||
if (available)
|
||||
worker = std::thread{[this] { Run(); }};
|
||||
#endif
|
||||
}
|
||||
~PreciseTimerScheduler() {
|
||||
#ifdef _WIN32
|
||||
stop.store(true);
|
||||
if (wake)
|
||||
SetEvent(wake);
|
||||
if (worker.joinable())
|
||||
worker.join();
|
||||
if (wake)
|
||||
CloseHandle(wake);
|
||||
#endif
|
||||
}
|
||||
bool Available() const {
|
||||
return available;
|
||||
}
|
||||
bool Add(Clock::time_point deadline, std::function<void()> callback) {
|
||||
if (!available)
|
||||
return false;
|
||||
{
|
||||
std::scoped_lock lock{mutex};
|
||||
queue.push({deadline, std::move(callback)});
|
||||
}
|
||||
#ifdef _WIN32
|
||||
SetEvent(wake);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
Clock::time_point deadline;
|
||||
std::function<void()> callback;
|
||||
bool operator>(const Entry& other) const {
|
||||
return deadline > other.deadline;
|
||||
}
|
||||
};
|
||||
#ifdef _WIN32
|
||||
void Run() {
|
||||
SetThreadDescription(GetCurrentThread(), L"shadPS4:PreciseTimers");
|
||||
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);
|
||||
while (!stop.load()) {
|
||||
std::unique_lock lock{mutex};
|
||||
if (queue.empty()) {
|
||||
lock.unlock();
|
||||
WaitForSingleObject(wake, INFINITE);
|
||||
continue;
|
||||
}
|
||||
const auto deadline = queue.top().deadline;
|
||||
const auto now = Clock::now();
|
||||
if (now >= deadline) {
|
||||
auto callback = queue.top().callback;
|
||||
queue.pop();
|
||||
lock.unlock();
|
||||
callback();
|
||||
continue;
|
||||
}
|
||||
lock.unlock();
|
||||
if (deadline - now > spin) {
|
||||
if (timer.Arm(deadline - now - spin)) {
|
||||
const HANDLE handles[]{wake, timer.Handle()};
|
||||
WaitForMultipleObjects(2, handles, FALSE, INFINITE);
|
||||
} else {
|
||||
WaitForSingleObject(wake, 1);
|
||||
}
|
||||
} else {
|
||||
// Recheck the queue while spinning so an earlier newly armed timer wins.
|
||||
_mm_pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowsSleepTimer timer;
|
||||
HANDLE wake{};
|
||||
std::thread worker;
|
||||
#endif
|
||||
std::atomic<bool> stop{false};
|
||||
bool available{};
|
||||
std::chrono::microseconds spin;
|
||||
std::mutex mutex;
|
||||
std::priority_queue<Entry, std::vector<Entry>, std::greater<>> queue;
|
||||
};
|
||||
} // namespace Common
|
||||
@@ -20,6 +20,7 @@
|
||||
#elif defined(_WIN32)
|
||||
#include <windows.h>
|
||||
#include "common/string_util.h"
|
||||
#include "common/windows_sleep_timer.h"
|
||||
#else
|
||||
#if defined(__Bitrig__) || defined(__DragonFly__) || defined(__FreeBSD__) || defined(__OpenBSD__)
|
||||
#include <pthread_np.h>
|
||||
@@ -109,16 +110,20 @@ void SetCurrentThreadPriority(ThreadPriority new_priority) {
|
||||
}
|
||||
|
||||
bool AccurateSleep(const std::chrono::nanoseconds duration, std::chrono::nanoseconds* remaining,
|
||||
const bool interruptible) {
|
||||
const bool interruptible, const bool high_resolution) {
|
||||
const auto begin_sleep = std::chrono::high_resolution_clock::now();
|
||||
|
||||
LARGE_INTEGER interval{
|
||||
.QuadPart = -1 * (duration.count() / 100u),
|
||||
};
|
||||
HANDLE timer = ::CreateWaitableTimer(NULL, TRUE, NULL);
|
||||
SetWaitableTimer(timer, &interval, 0, NULL, NULL, 0);
|
||||
const auto ret = WaitForSingleObjectEx(timer, INFINITE, interruptible);
|
||||
::CloseHandle(timer);
|
||||
DWORD ret;
|
||||
if (high_resolution) {
|
||||
thread_local WindowsSleepTimer timer;
|
||||
ret = timer.Wait(duration, interruptible);
|
||||
} else {
|
||||
LARGE_INTEGER interval{.QuadPart = -1 * (duration.count() / 100u)};
|
||||
HANDLE timer = ::CreateWaitableTimer(NULL, TRUE, NULL);
|
||||
SetWaitableTimer(timer, &interval, 0, NULL, NULL, 0);
|
||||
ret = WaitForSingleObjectEx(timer, INFINITE, interruptible);
|
||||
::CloseHandle(timer);
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
const auto end_sleep = std::chrono::high_resolution_clock::now();
|
||||
@@ -149,7 +154,7 @@ void SetCurrentThreadPriority(ThreadPriority new_priority) {
|
||||
}
|
||||
|
||||
bool AccurateSleep(const std::chrono::nanoseconds duration, std::chrono::nanoseconds* remaining,
|
||||
const bool interruptible) {
|
||||
const bool interruptible, [[maybe_unused]] const bool high_resolution) {
|
||||
timespec request = {
|
||||
.tv_sec = duration.count() / 1'000'000'000,
|
||||
.tv_nsec = duration.count() % 1'000'000'000,
|
||||
@@ -231,13 +236,13 @@ void SetThreadName(void* thread, const char* name) {
|
||||
|
||||
#endif
|
||||
|
||||
AccurateTimer::AccurateTimer(std::chrono::nanoseconds target_interval)
|
||||
: target_interval(target_interval) {}
|
||||
AccurateTimer::AccurateTimer(std::chrono::nanoseconds target_interval, bool high_resolution)
|
||||
: target_interval(target_interval), high_resolution(high_resolution) {}
|
||||
|
||||
void AccurateTimer::Start() {
|
||||
const auto begin_sleep = std::chrono::high_resolution_clock::now();
|
||||
if (total_wait.count() > 0) {
|
||||
AccurateSleep(total_wait, nullptr, false);
|
||||
AccurateSleep(total_wait, nullptr, false, high_resolution);
|
||||
}
|
||||
start_time = std::chrono::high_resolution_clock::now();
|
||||
total_wait -= std::chrono::duration_cast<std::chrono::nanoseconds>(start_time - begin_sleep);
|
||||
|
||||
@@ -27,16 +27,21 @@ void SetCurrentThreadName(const char* name);
|
||||
void SetThreadName(void* thread, const char* name);
|
||||
|
||||
bool AccurateSleep(std::chrono::nanoseconds duration, std::chrono::nanoseconds* remaining,
|
||||
bool interruptible);
|
||||
bool interruptible, bool high_resolution = false);
|
||||
|
||||
class AccurateTimer {
|
||||
std::chrono::nanoseconds target_interval{};
|
||||
std::chrono::nanoseconds total_wait{};
|
||||
const bool high_resolution;
|
||||
|
||||
std::chrono::high_resolution_clock::time_point start_time;
|
||||
|
||||
public:
|
||||
explicit AccurateTimer(std::chrono::nanoseconds target_interval);
|
||||
explicit AccurateTimer(std::chrono::nanoseconds target_interval, bool high_resolution = false);
|
||||
|
||||
void SetInterval(std::chrono::nanoseconds interval) {
|
||||
target_interval = interval;
|
||||
}
|
||||
|
||||
void Start();
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <windows.h>
|
||||
|
||||
namespace Common {
|
||||
// Reused only by its owning thread. No global timer-resolution change or busy wait.
|
||||
class WindowsSleepTimer {
|
||||
public:
|
||||
explicit WindowsSleepTimer(bool high_resolution = true) {
|
||||
timer = CreateWaitableTimerExW(nullptr, nullptr,
|
||||
high_resolution ? CREATE_WAITABLE_TIMER_HIGH_RESOLUTION : 0,
|
||||
TIMER_MODIFY_STATE | SYNCHRONIZE);
|
||||
precise = timer && high_resolution;
|
||||
if (!timer && high_resolution) {
|
||||
timer = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_MODIFY_STATE | SYNCHRONIZE);
|
||||
}
|
||||
}
|
||||
~WindowsSleepTimer() {
|
||||
if (timer)
|
||||
CloseHandle(timer);
|
||||
}
|
||||
WindowsSleepTimer(const WindowsSleepTimer&) = delete;
|
||||
WindowsSleepTimer& operator=(const WindowsSleepTimer&) = delete;
|
||||
DWORD Wait(std::chrono::nanoseconds duration, bool interruptible) {
|
||||
if (duration.count() <= 0)
|
||||
return WAIT_OBJECT_0;
|
||||
if (!Arm(duration))
|
||||
return WAIT_FAILED;
|
||||
return WaitForSingleObjectEx(timer, INFINITE, interruptible);
|
||||
}
|
||||
|
||||
bool Arm(std::chrono::nanoseconds duration) {
|
||||
if (!timer)
|
||||
return false;
|
||||
LARGE_INTEGER interval;
|
||||
interval.QuadPart = -std::max<long long>(1, duration.count() / 100);
|
||||
return SetWaitableTimer(timer, &interval, 0, nullptr, nullptr, FALSE) != FALSE;
|
||||
}
|
||||
HANDLE Handle() const {
|
||||
return timer;
|
||||
}
|
||||
bool IsHighResolution() const {
|
||||
return precise;
|
||||
}
|
||||
|
||||
private:
|
||||
HANDLE timer{};
|
||||
bool precise{};
|
||||
};
|
||||
} // namespace Common
|
||||
@@ -45,9 +45,8 @@ constexpr VAddr USER_MIN = 0x7000000000ULL;
|
||||
constexpr VAddr SYSTEM_RESERVED_MAX = 0xFFFFFFFFFULL;
|
||||
constexpr VAddr USER_MIN = 0x1000000000ULL;
|
||||
#endif
|
||||
#if defined(ENABLE_BACHATA_RUNTIME)
|
||||
// Android devices commonly expose a 39-bit userspace. Keep the managed guest range below
|
||||
// native mappings near the top of that space; compact titles use addresses inside this range.
|
||||
#if defined(ENABLE_BACHATA_RUNTIME) || defined(SHADPS4_ENABLE_FEX_GUEST_CPU)
|
||||
// Keep FEX guest addresses fixed below native mappings in a compact 256 GB range.
|
||||
constexpr VAddr USER_MAX = 0x3FFFFFFFFFULL;
|
||||
#elif defined(__linux__)
|
||||
// Linux maps the shadPS4 executable around here, so limit the user maximum
|
||||
@@ -648,7 +647,7 @@ struct AddressSpace::Impl {
|
||||
map_flags |= MAP_NORESERVE;
|
||||
#endif
|
||||
|
||||
#if defined(ENABLE_BACHATA_RUNTIME)
|
||||
#if defined(ENABLE_BACHATA_RUNTIME) || defined(SHADPS4_ENABLE_FEX_GUEST_CPU)
|
||||
std::vector<std::pair<VAddr, VAddr>> occupied_ranges;
|
||||
std::ifstream process_maps{"/proc/self/maps"};
|
||||
for (std::string line; std::getline(process_maps, line);) {
|
||||
@@ -743,7 +742,7 @@ struct AddressSpace::Impl {
|
||||
m_free_regions.insert({system_managed_addr, system_managed_addr + system_managed_size});
|
||||
m_free_regions.insert({system_reserved_addr, system_reserved_addr + system_reserved_size});
|
||||
m_free_regions.insert({user_addr, user_addr + user_size});
|
||||
#ifdef ENABLE_BACHATA_RUNTIME
|
||||
#if defined(ENABLE_BACHATA_RUNTIME) || defined(SHADPS4_ENABLE_FEX_GUEST_CPU)
|
||||
for (const auto [mapped_begin, mapped_end] : occupied_ranges) {
|
||||
const VAddr begin = std::max(mapped_begin, SYSTEM_MANAGED_MIN);
|
||||
const VAddr end = std::min(mapped_end, USER_MAX + 1);
|
||||
@@ -762,7 +761,7 @@ struct AddressSpace::Impl {
|
||||
}
|
||||
shm_unlink(shm_path.c_str());
|
||||
#else
|
||||
#if !defined(__FreeBSD__) && !defined(ENABLE_BACHATA_RUNTIME)
|
||||
#if !defined(__FreeBSD__) && !defined(ENABLE_BACHATA_RUNTIME) && !defined(SHADPS4_ENABLE_FEX_GUEST_CPU)
|
||||
madvise(virtual_base, virtual_size, MADV_HUGEPAGE);
|
||||
#endif
|
||||
// NOTE: If you add MFD_HUGETLB or whatever, remember that FBSD will break (libc bug)
|
||||
|
||||
@@ -272,7 +272,19 @@ void L::DrawAdvanced() {
|
||||
}
|
||||
|
||||
void L::DrawSimple() {
|
||||
const float frameRate = DebugState.Framerate;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static auto counted_at = Clock::now();
|
||||
static auto counted_frames = DebugState.gnm_frame_count.load();
|
||||
static float title_rate = 0.0f;
|
||||
const auto now = Clock::now();
|
||||
const float elapsed = std::chrono::duration<float>(now - counted_at).count();
|
||||
if (elapsed >= 0.5f) {
|
||||
const auto frames = DebugState.gnm_frame_count.load();
|
||||
title_rate = static_cast<float>(frames - counted_frames) / elapsed;
|
||||
counted_frames = frames;
|
||||
counted_at = now;
|
||||
}
|
||||
const float frameRate = title_rate >= 1.0f ? title_rate : DebugState.Framerate;
|
||||
if (frameRate < 10) {
|
||||
PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.0f, 0.0f, 1.0f)); // Red
|
||||
} else if (frameRate >= 10 && frameRate < 20) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -16,11 +17,10 @@
|
||||
|
||||
#include "common/elf_info.h"
|
||||
#include "common/logging/log.h"
|
||||
#include "common/singleton.h"
|
||||
#include "core/emulator_settings.h"
|
||||
#include "core/known_title.h"
|
||||
#include "core/linker.h"
|
||||
#include "core/module.h"
|
||||
#include "core/known_title_builds.h"
|
||||
#include "core/memory.h"
|
||||
#include "core/vr/vr_runtime.h"
|
||||
#include "video_core/renderer_vulkan/vk_scheduler.h"
|
||||
|
||||
@@ -30,74 +30,44 @@ namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
// Astro Bot Rescue Mission, CUSA12392, in the build whose code at SetRecentre reads as below
|
||||
// (the function of its tracking manager that asks for the head's position to be taken anew).
|
||||
constexpr u64 SetRecentre = 0xc48320;
|
||||
constexpr u8 SetRecentreCode[] = {0x40, 0x0f, 0xb6, 0xc6, 0xff, 0xc0, 0x89, 0x07, 0xc3};
|
||||
// Astro Bot Rescue Mission, CUSA12392/CUSA12431. Where things are in each known build
|
||||
// inside, and how a build is told from the others, is in known_title_builds.h; what follows is
|
||||
// the same in all of them.
|
||||
using Builds::Build;
|
||||
using Builds::ConsoleSizes;
|
||||
using Builds::FirstHeadsetLevel;
|
||||
using Builds::LastHeadsetLevel;
|
||||
|
||||
// The tracking manager, a singleton: the point it counts positions from, and for the headset
|
||||
// the position counted from it.
|
||||
constexpr u64 ManagerPointer = 0x2e025a8;
|
||||
constexpr u64 ManagerOrigin = 0x6210;
|
||||
constexpr u64 ManagerHeadState = 0x6848;
|
||||
constexpr u64 StatePosition = 0x10;
|
||||
constexpr u64 StatePositionValid = 0x54;
|
||||
|
||||
// The engine's frame rate and what it derives from it when the rate is set (its function at
|
||||
// 0xe48fd0): the time one frame stands for, in seconds and in microseconds. Everything in the
|
||||
// game that moves reads one of the latter two.
|
||||
constexpr u64 EngineFrameRate = 0x16688a8; // double
|
||||
constexpr u64 EngineFrameSeconds = 0x16688b0; // float
|
||||
constexpr u64 EngineFrameMicroseconds = 0x16688b8; // u64
|
||||
constexpr u64 ImageEnd = EngineFrameMicroseconds + sizeof(u64);
|
||||
|
||||
// What sets the size the scene is drawn at, a singleton made when first asked for: the size is
|
||||
// one of a list (ConsoleSizes, the first three are for a television), a base (4 on the
|
||||
// console, 6 on its Pro model) plus an offset that the game moves between a lowest and a
|
||||
// highest one by how long it finds the GPU to take over its drawing. It reads that off
|
||||
// timestamps which, emulated, tell how long the emulator took to pass the drawing on, not how
|
||||
// long the GPU takes over it: left to itself, the game draws large where the GPU is busiest.
|
||||
constexpr u64 ResolutionPointer = 0x2dff9e0;
|
||||
constexpr u64 ResolutionBase = 0x0; // s32
|
||||
constexpr u64 ResolutionLevel = 0x4; // s32
|
||||
constexpr u64 ResolutionOffset = 0x20; // s32
|
||||
constexpr u64 ResolutionHighest = 0x28; // s32, an offset
|
||||
constexpr u64 ResolutionLowest = 0x2c; // s32, an offset
|
||||
constexpr s32 FirstHeadsetLevel = 3;
|
||||
constexpr s32 LastHeadsetLevel = 6;
|
||||
// The sizes of the list, an eye, as the console has them.
|
||||
constexpr std::array<std::array<u32, 2>, 7> ConsoleSizes{{
|
||||
{640, 360}, {1280, 720}, {1920, 1080}, {816, 870}, {960, 1080}, {1200, 1280}, {1440, 1536}}};
|
||||
|
||||
// Where the sizes of the headset's list are written down (see Larger): a table of widths, one of
|
||||
// heights, the same once more in the switch of the function that makes the scene's targets
|
||||
// (heights, then widths), and a table of pixel counts the title's own choice goes by.
|
||||
constexpr u64 SizeWidths = 0x12da900; // u32 [7]
|
||||
constexpr u64 SizeHeights = 0x12da920; // u32 [7]
|
||||
constexpr std::array<std::array<u64, 2>, 4> SizeSwitch{{
|
||||
{0xf2242f, 0xf22435}, {0xf2240d, 0xf22413}, {0xf22551, 0xf22557}, {0xf2255f, 0xf22565}}};
|
||||
constexpr u64 SizePixels = 0x1645048; // u64, 32 bytes apart
|
||||
// The pictures handed to the headset, made three pairs at a time: their width and height.
|
||||
constexpr std::array<u64, 4> EyeSizes{0xc3fad0, 0xc3fad5, 0xc3fb5c, 0xc3fb61};
|
||||
// What the targets are taken from: a pool of 200 MB (its size in two places), a smaller one of
|
||||
// 10 MB for what goes with them (in three), and the heap of graphics memory both come from,
|
||||
// 872 MB, which the title takes from the console's memory at its start.
|
||||
constexpr std::array<u64, 2> TargetPool{0xef8bf7, 0xef8c4d};
|
||||
constexpr std::array<u64, 3> SmallPool{0xf22194, 0xf221be, 0xf221dd};
|
||||
constexpr u64 GraphicsHeap = 0x1269708; // u64
|
||||
constexpr u32 ConsoleTargetPool = 0xc800000;
|
||||
constexpr u32 ConsoleSmallPool = 0xa00000;
|
||||
constexpr u64 ConsoleGraphicsHeap = 0x36800000;
|
||||
/// The build of the title that runs, and where its image starts, from the moment its image is
|
||||
/// loaded and found to be one of those known (OnGameLoaded); nullptr for every other title and
|
||||
/// every other build.
|
||||
std::atomic<const Build*> known_build{nullptr};
|
||||
VAddr known_base{};
|
||||
|
||||
/// The title drawing larger than it does on the console: every size of the headset's list (and
|
||||
/// the pictures handed to the headset) grown by the same factor, and the memory that takes.
|
||||
/// SHADPS4_TITLE_EYE_WIDTH=<pixels> is the width of the largest, 1440 on the console.
|
||||
struct Larger {
|
||||
struct Larger : Builds::Sizes {
|
||||
double factor{1.0};
|
||||
std::array<std::array<u32, 2>, 7> sizes{ConsoleSizes};
|
||||
u32 target_pool{ConsoleTargetPool};
|
||||
u32 small_pool{ConsoleSmallPool};
|
||||
u64 graphics_heap{ConsoleGraphicsHeap};
|
||||
/// What the console's memory has to grow by for it, in MB.
|
||||
s32 extra_memory_mb{};
|
||||
};
|
||||
@@ -124,12 +94,12 @@ const Larger& GetLarger() {
|
||||
const auto megabytes = [&](u64 bytes) {
|
||||
return (static_cast<u64>(static_cast<double>(bytes) * pixels * 1.1) + MB - 1) / MB * MB;
|
||||
};
|
||||
result.target_pool = static_cast<u32>(megabytes(ConsoleTargetPool));
|
||||
result.small_pool = static_cast<u32>(megabytes(ConsoleSmallPool));
|
||||
const u64 growth = (result.target_pool - ConsoleTargetPool) +
|
||||
(result.small_pool - ConsoleSmallPool) +
|
||||
result.target_pool = static_cast<u32>(megabytes(Builds::ConsoleTargetPool));
|
||||
result.small_pool = static_cast<u32>(megabytes(Builds::ConsoleSmallPool));
|
||||
const u64 growth = (result.target_pool - Builds::ConsoleTargetPool) +
|
||||
(result.small_pool - Builds::ConsoleSmallPool) +
|
||||
static_cast<u64>(200.0 * MB * (pixels - 1.0));
|
||||
result.graphics_heap = ConsoleGraphicsHeap + growth;
|
||||
result.graphics_heap = Builds::ConsoleGraphicsHeap + growth;
|
||||
result.extra_memory_mb = static_cast<s32>((growth + 256 * MB) / (256 * MB) * 256);
|
||||
return result;
|
||||
}();
|
||||
@@ -145,30 +115,6 @@ std::string SizeName(s32 level) {
|
||||
return fmt::format("{}x{}", size[0], size[1]);
|
||||
}
|
||||
|
||||
/// Where the title's image starts if it is the build described above, 0 otherwise.
|
||||
VAddr KnownBase() {
|
||||
static const VAddr base = []() -> VAddr {
|
||||
if (Common::ElfInfo::Instance().GameSerial() != "CUSA12392") {
|
||||
return 0;
|
||||
}
|
||||
const Module* eboot = Common::Singleton<Linker>::Instance()->GetModule(0);
|
||||
if (eboot == nullptr || !eboot->IsValid() ||
|
||||
eboot->aligned_base_size < std::max(ManagerPointer + sizeof(u64), ImageEnd)) {
|
||||
return 0;
|
||||
}
|
||||
const VAddr image = eboot->GetBaseAddress();
|
||||
if (std::memcmp(reinterpret_cast<const void*>(image + SetRecentre), SetRecentreCode,
|
||||
sizeof(SetRecentreCode)) != 0) {
|
||||
LOG_INFO(Core, "This is another build of CUSA12392 than the one known from inside: "
|
||||
"it is left to itself");
|
||||
return 0;
|
||||
}
|
||||
LOG_INFO(Core, "CUSA12392 in the build known from inside");
|
||||
return image;
|
||||
}();
|
||||
return base;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
T Read(VAddr address) {
|
||||
T value;
|
||||
@@ -191,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};
|
||||
@@ -204,7 +152,7 @@ struct Settings {
|
||||
|
||||
/// Refreshes of the display a frame is given: 0 is chosen by what the title manages, 1
|
||||
/// and more is that many throughout.
|
||||
s32 pace{};
|
||||
s32 pace{1};
|
||||
/// The fewest the choice may come to where the display refreshes faster than the title
|
||||
/// was made to draw: 2 is the title's own way (a frame for every two refreshes), 1 has it
|
||||
/// draw a frame for every refresh, faster than it ever did on the console.
|
||||
@@ -212,6 +160,12 @@ struct Settings {
|
||||
/// The most frames a second wanted, 0 for no such limit: frames are given at least as many
|
||||
/// refreshes as keep them to that.
|
||||
double fps_cap{};
|
||||
|
||||
/// The title's physics take each step with the time step its bodies were sent with
|
||||
/// (Builds::PhysicsStepChanges): what a time step that changes needs.
|
||||
bool physics_step{true};
|
||||
/// Where the bodies the title moves by hand end up goes to the log (PhysicsWatch).
|
||||
bool physics_watch{false};
|
||||
};
|
||||
|
||||
const Settings& GetSettings() {
|
||||
@@ -234,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);
|
||||
@@ -251,6 +209,14 @@ const Settings& GetSettings() {
|
||||
parsed.fps_cap = std::min(cap, 240.0);
|
||||
}
|
||||
}
|
||||
// SHADPS4_TITLE_PHYSICS_STEP=0 leaves the title's physics as the console has them;
|
||||
// SHADPS4_TITLE_PHYSICS_WATCH=1 tells in the log where its bodies end up.
|
||||
if (const char* value = std::getenv("SHADPS4_TITLE_PHYSICS_STEP"); value != nullptr) {
|
||||
parsed.physics_step = std::atoi(value) != 0;
|
||||
}
|
||||
if (const char* value = std::getenv("SHADPS4_TITLE_PHYSICS_WATCH"); value != nullptr) {
|
||||
parsed.physics_watch = std::atoi(value) != 0;
|
||||
}
|
||||
return parsed;
|
||||
}();
|
||||
return settings;
|
||||
@@ -606,14 +572,19 @@ private:
|
||||
|
||||
// The size the title draws its scene at right now, as an index into ConsoleSizes; -1 when
|
||||
/// it has not got that far. Holds it to `wanted` on the way unless that is 0.
|
||||
s32 TendResolution(VAddr base, s32 wanted) {
|
||||
const u64 control = Read<u64>(base + ResolutionPointer);
|
||||
s32 TendResolution(VAddr base, const Build& build, s32 wanted) {
|
||||
const u64 control = Read<u64>(base + build.resolution_pointer);
|
||||
if (control == 0) {
|
||||
return -1;
|
||||
}
|
||||
const s32 level = Read<s32>(control + ResolutionLevel);
|
||||
const s32 base_level = Read<s32>(control + ResolutionBase);
|
||||
if (level < 0 || level > LastHeadsetLevel || base_level < 0 || base_level > LastHeadsetLevel) {
|
||||
// Not what is known of it: looked at, never written to.
|
||||
return -1;
|
||||
}
|
||||
if (wanted != 0 && level >= FirstHeadsetLevel) {
|
||||
const s32 offset = wanted - Read<s32>(control + ResolutionBase);
|
||||
const s32 offset = wanted - base_level;
|
||||
Write<s32>(control + ResolutionOffset, offset);
|
||||
Write<s32>(control + ResolutionHighest, offset);
|
||||
Write<s32>(control + ResolutionLowest, offset);
|
||||
@@ -664,13 +635,164 @@ private:
|
||||
double stepped{};
|
||||
};
|
||||
|
||||
/// Whether the bodies the title moves by hand arrive where it sends them, for the log
|
||||
/// (SHADPS4_TITLE_PHYSICS_WATCH=1). A body that is sent somewhere is given the speed that
|
||||
/// takes it there in one step, worked out with the time step the library has written down
|
||||
/// (Builds::PhysicsStepChanges), and keeps that speed: how far a step moved it, against its
|
||||
/// speed times the time step that was written down when it was sent, tells whether it got
|
||||
/// there. Looked at when the title reads its controller, once a frame on the thread it runs
|
||||
/// on and before anything of the frame has moved: the title sends its bodies and then takes
|
||||
/// the step, both after that.
|
||||
class PhysicsWatch {
|
||||
public:
|
||||
void Look(VAddr base, const Build& build) {
|
||||
namespace Physics = Builds::Physics;
|
||||
std::scoped_lock lock{mutex};
|
||||
auto* const memory = Core::Memory::Instance();
|
||||
const auto there = [&](u64 address, u64 size) {
|
||||
return address != 0 && (address & 3) == 0 && memory->IsValidMapping(address, size);
|
||||
};
|
||||
const u64 game = Read<u64>(base + build.game_pointer);
|
||||
if (!there(game, Physics::GameWorld + 8)) {
|
||||
return;
|
||||
}
|
||||
const u64 world = Read<u64>(game + Physics::GameWorld);
|
||||
if (!there(world, Physics::WorldInner + 8)) {
|
||||
return;
|
||||
}
|
||||
const u64 inner = Read<u64>(world + Physics::WorldInner);
|
||||
if (!there(inner, Physics::InnerScale + 4)) {
|
||||
return;
|
||||
}
|
||||
const u64 library = Read<u64>(inner + Physics::InnerLibrary);
|
||||
if (!there(library, Physics::LibraryTimeStep + 4)) {
|
||||
return;
|
||||
}
|
||||
const u32 count = Read<u32>(library + Physics::LibraryBodyCount);
|
||||
const u64 bodies = Read<u64>(library + Physics::LibraryBodies);
|
||||
const double scale = Read<float>(inner + Physics::InnerScale);
|
||||
const double time_step = Read<float>(library + Physics::LibraryTimeStep);
|
||||
if (count == 0 || count > MostBodies || !there(bodies, count * Physics::BodySize) ||
|
||||
!(scale > 0.0) || !(time_step > 0.0)) {
|
||||
return;
|
||||
}
|
||||
if (bodies != seen_bodies) {
|
||||
// Another world, or another level's.
|
||||
seen_bodies = bodies;
|
||||
seen.clear();
|
||||
}
|
||||
seen.resize(count);
|
||||
bool stepped = false;
|
||||
for (u32 i = 0; i < count; ++i) {
|
||||
const VAddr body = bodies + u64{i} * Physics::BodySize;
|
||||
Seen& was = seen[i];
|
||||
const bool by_hand = Read<u8>(body + Physics::BodyMotion) == Physics::MotionKeyframe;
|
||||
const auto segment = Read<std::array<s32, 3>>(body + Physics::BodySegment);
|
||||
const auto position = Read<std::array<float, 3>>(body + Physics::BodyPosition);
|
||||
if (was.by_hand && by_hand && segment == was.segment && position != was.position) {
|
||||
// It moved: a step was taken since it was last looked at, with the speed it
|
||||
// still has.
|
||||
const auto velocity = Read<std::array<float, 3>>(body + Physics::BodyVelocity);
|
||||
double way = 0.0;
|
||||
double off = 0.0;
|
||||
for (u32 axis = 0; axis < 3; ++axis) {
|
||||
const double moved = position[axis] - was.position[axis];
|
||||
const double sent = velocity[axis] * written_down;
|
||||
way += sent * sent;
|
||||
off += (moved - sent) * (moved - sent);
|
||||
}
|
||||
way = std::sqrt(way) / scale;
|
||||
off = std::sqrt(off) / scale;
|
||||
// (A body further from where it was sent than it had to go twice over is
|
||||
// another body in the place of the one that was looked at.)
|
||||
if (way > 0.0 && off <= 2.0 * way + 0.01) {
|
||||
stepped = true;
|
||||
++moves;
|
||||
missed += off > Off ? 1 : 0;
|
||||
farthest = std::max(farthest, way);
|
||||
if (off > worst_off) {
|
||||
worst_off = off;
|
||||
worst_way = way;
|
||||
}
|
||||
}
|
||||
}
|
||||
was.by_hand = by_hand;
|
||||
was.segment = segment;
|
||||
was.position = position;
|
||||
}
|
||||
if (stepped && written_down > 0.0) {
|
||||
// How unlike two time steps in a row are, which is what the bodies are off by.
|
||||
most_unlike = std::max(most_unlike, std::abs(time_step / written_down - 1.0));
|
||||
}
|
||||
written_down = time_step;
|
||||
}
|
||||
|
||||
/// What was seen since this was last asked, "" for nothing.
|
||||
std::string TakeSummary() {
|
||||
std::scoped_lock lock{mutex};
|
||||
if (moves == 0) {
|
||||
return {};
|
||||
}
|
||||
std::string summary = fmt::format(
|
||||
"The title's bodies: {} times one was sent somewhere and moved, {:.2f} at the "
|
||||
"farthest; where they ended was {:.4f} from where they were sent at worst (by one "
|
||||
"sent {:.2f}), {} times more than {} away; two time steps in a row were {:.1f}% "
|
||||
"apart at most",
|
||||
moves, farthest, worst_off, worst_way, missed, Off, most_unlike * 100.0);
|
||||
moves = 0;
|
||||
missed = 0;
|
||||
farthest = 0.0;
|
||||
worst_off = 0.0;
|
||||
worst_way = 0.0;
|
||||
most_unlike = 0.0;
|
||||
return summary;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Seen {
|
||||
bool by_hand{};
|
||||
std::array<s32, 3> segment{};
|
||||
std::array<float, 3> position{};
|
||||
};
|
||||
|
||||
// More than any level has: a count that is not one.
|
||||
static constexpr u32 MostBodies = 1u << 17;
|
||||
// What counts as not having arrived, in the game's units (a block of a level is about
|
||||
// half of one).
|
||||
static constexpr double Off = 0.01;
|
||||
|
||||
std::mutex mutex;
|
||||
u64 seen_bodies{};
|
||||
std::vector<Seen> seen;
|
||||
/// The library's time step when the bodies were last looked at: what they are sent with
|
||||
/// until the step that follows.
|
||||
double written_down{};
|
||||
u64 moves{};
|
||||
u64 missed{};
|
||||
double farthest{};
|
||||
double worst_off{};
|
||||
double worst_way{};
|
||||
double most_unlike{};
|
||||
};
|
||||
|
||||
PhysicsWatch physics_watch;
|
||||
|
||||
} // namespace
|
||||
|
||||
void OnFrameSubmitted() {
|
||||
const VAddr base = KnownBase();
|
||||
if (base == 0) {
|
||||
void OnControllerRead() {
|
||||
const Build* const build = known_build.load(std::memory_order_acquire);
|
||||
if (build == nullptr || !GetSettings().physics_watch) {
|
||||
return;
|
||||
}
|
||||
physics_watch.Look(known_base, *build);
|
||||
}
|
||||
|
||||
void OnFrameSubmitted() {
|
||||
const Build* const build = known_build.load(std::memory_order_acquire);
|
||||
if (build == nullptr) {
|
||||
return;
|
||||
}
|
||||
const VAddr base = known_base;
|
||||
const Settings& settings = GetSettings();
|
||||
|
||||
static bool running = false;
|
||||
@@ -714,17 +836,21 @@ void OnFrameSubmitted() {
|
||||
wanted = governor.Level(frame, now, 0);
|
||||
break;
|
||||
}
|
||||
const s32 resolution = TendResolution(base, wanted);
|
||||
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
|
||||
// was made for: a frame for every refresh of a display faster than 60 Hz.
|
||||
const double shortest = governor.Pace() == 1 ? 1.0 / 250.0 : Nominal;
|
||||
step = time_step.Next(frame, settings.longest_step, shortest);
|
||||
Write<double>(base + EngineFrameRate, 1.0 / step);
|
||||
Write<float>(base + EngineFrameSeconds, static_cast<float>(step));
|
||||
Write<u64>(base + EngineFrameMicroseconds, static_cast<u64>(step * 1e6));
|
||||
Write<double>(base + build->frame_rate, 1.0 / step);
|
||||
Write<float>(base + build->frame_seconds, static_cast<float>(step));
|
||||
Write<u64>(base + build->frame_microseconds, static_cast<u64>(step * 1e6));
|
||||
}
|
||||
|
||||
if (frame <= Stall) {
|
||||
@@ -744,6 +870,9 @@ void OnFrameSubmitted() {
|
||||
report_real, 100.0 * Nominal * report_frames / report_real,
|
||||
SizeName(resolution),
|
||||
summary.pace, summary.slot * 1e3, summary.load * 100.0);
|
||||
if (const std::string seen = physics_watch.TakeSummary(); !seen.empty()) {
|
||||
LOG_INFO(Core, "{}", seen);
|
||||
}
|
||||
report_time = now;
|
||||
report_real = 0.0;
|
||||
report_frames = 0;
|
||||
@@ -754,8 +883,14 @@ 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() {
|
||||
if (Common::ElfInfo::Instance().GameSerial() != "CUSA12392") {
|
||||
const auto& serial = Common::ElfInfo::Instance().GameSerial();
|
||||
if (serial != "CUSA12392" && serial != "CUSA12431") {
|
||||
return;
|
||||
}
|
||||
const Larger& larger = GetLarger();
|
||||
@@ -770,74 +905,85 @@ void Prepare() {
|
||||
}
|
||||
|
||||
void OnGameLoaded(VAddr base, u64 size) {
|
||||
if (Common::ElfInfo::Instance().GameSerial() != "CUSA12392" || size < ImageEnd ||
|
||||
std::memcmp(reinterpret_cast<const void*>(base + SetRecentre), SetRecentreCode,
|
||||
sizeof(SetRecentreCode)) != 0) {
|
||||
const auto& serial = Common::ElfInfo::Instance().GameSerial();
|
||||
if (serial != "CUSA12392" && serial != "CUSA12431") {
|
||||
return;
|
||||
}
|
||||
const Larger& larger = GetLarger();
|
||||
if (larger.factor == 1.0) {
|
||||
// Asked here and nowhere else: this is the one moment at which the image is as its build
|
||||
// has it. Its sizes may be other ones from here on, and its clock is once it runs.
|
||||
const std::span<u8> image{reinterpret_cast<u8*>(base), static_cast<size_t>(size)};
|
||||
const Build* const build = Builds::Recognise(image);
|
||||
if (build == nullptr) {
|
||||
LOG_WARNING(Core,
|
||||
"This build of {} is none of those known from inside (its versions "
|
||||
"1.00 and 1.04 are): it is left to itself. It then counts time in frames, "
|
||||
"which is slow motion wherever a frame takes longer than a sixtieth of a "
|
||||
"second, and draws at the console's sizes", serial);
|
||||
return;
|
||||
}
|
||||
// Every place is checked for what the console's build has there before anything is
|
||||
// written: a title that turns out to be other than thought is left as it is.
|
||||
struct Change {
|
||||
u64 at;
|
||||
u64 was;
|
||||
u64 now;
|
||||
u32 bytes;
|
||||
};
|
||||
std::vector<Change> changes;
|
||||
for (s32 level = FirstHeadsetLevel; level <= LastHeadsetLevel; ++level) {
|
||||
const auto& was = ConsoleSizes[level];
|
||||
const auto& now = larger.sizes[level];
|
||||
changes.push_back({SizeWidths + 4 * level, was[0], now[0], 4});
|
||||
changes.push_back({SizeHeights + 4 * level, was[1], now[1], 4});
|
||||
changes.push_back({SizeSwitch[level - FirstHeadsetLevel][0], was[1], now[1], 4});
|
||||
changes.push_back({SizeSwitch[level - FirstHeadsetLevel][1], was[0], now[0], 4});
|
||||
changes.push_back({SizePixels + 32 * level, u64{was[0]} * was[1], u64{now[0]} * now[1], 8});
|
||||
}
|
||||
const auto& eye_was = ConsoleSizes[LastHeadsetLevel];
|
||||
const auto& eye_now = larger.sizes[LastHeadsetLevel];
|
||||
for (u32 i = 0; i < EyeSizes.size(); ++i) {
|
||||
changes.push_back({EyeSizes[i], eye_was[i % 2], eye_now[i % 2], 4});
|
||||
}
|
||||
for (const u64 at : TargetPool) {
|
||||
changes.push_back({at, ConsoleTargetPool, larger.target_pool, 4});
|
||||
}
|
||||
for (const u64 at : SmallPool) {
|
||||
changes.push_back({at, ConsoleSmallPool, larger.small_pool, 4});
|
||||
}
|
||||
changes.push_back({GraphicsHeap, ConsoleGraphicsHeap, larger.graphics_heap, 8});
|
||||
LOG_INFO(Core, "{} in a build known from inside: {}", serial, build->name);
|
||||
|
||||
for (const Change& change : changes) {
|
||||
u64 found = 0;
|
||||
std::memcpy(&found, reinterpret_cast<const void*>(base + change.at), change.bytes);
|
||||
if (found != change.was) {
|
||||
const Larger& larger = GetLarger();
|
||||
if (larger.factor != 1.0) {
|
||||
// Every place is checked for what the console's build has there before anything is
|
||||
// written: a title that turns out to be other than thought is left as it is.
|
||||
const auto changes = Builds::SizeChanges(*build, larger);
|
||||
if (const Builds::Change* unexpected = Builds::Apply(image, changes);
|
||||
unexpected != nullptr) {
|
||||
LOG_WARNING(Core,
|
||||
"The title has {:#x} at {:#x} where {:#x} was expected: it draws at the "
|
||||
"The title does not have {:#x} at {:#x} as expected: it draws at the "
|
||||
"console's sizes",
|
||||
found, change.at, change.was);
|
||||
return;
|
||||
unexpected->was, unexpected->at);
|
||||
} else {
|
||||
LOG_INFO(Core,
|
||||
"The title draws at up to {} an eye instead of 1440x1536 ({:.2f} times as "
|
||||
"wide), the smallest {}; its render targets have {} MB, its graphics memory "
|
||||
"{} MB",
|
||||
SizeName(LastHeadsetLevel), larger.factor, SizeName(FirstHeadsetLevel),
|
||||
larger.target_pool >> 20, larger.graphics_heap >> 20);
|
||||
}
|
||||
}
|
||||
for (const Change& change : changes) {
|
||||
std::memcpy(reinterpret_cast<void*>(base + change.at), &change.now, change.bytes);
|
||||
if (const Settings& settings = GetSettings(); settings.time_step && settings.physics_step) {
|
||||
const auto changes = Builds::PhysicsStepChanges(*build);
|
||||
if (const Builds::Change* unexpected = Builds::Apply(image, changes);
|
||||
unexpected != nullptr) {
|
||||
LOG_WARNING(Core,
|
||||
"The title does not have {:#x} at {:#x} as expected: its physics are "
|
||||
"left as they are, and collisions that it moves may end up beside what "
|
||||
"is drawn",
|
||||
unexpected->was, unexpected->at);
|
||||
} else {
|
||||
LOG_INFO(Core, "The title's physics take every step with the time step its bodies "
|
||||
"were sent with");
|
||||
}
|
||||
}
|
||||
LOG_INFO(Core,
|
||||
"The title draws at up to {} an eye instead of 1440x1536 ({:.2f} times as wide), "
|
||||
"the smallest {}; its render targets have {} MB, its graphics memory {} MB",
|
||||
SizeName(LastHeadsetLevel), larger.factor, SizeName(FirstHeadsetLevel),
|
||||
larger.target_pool >> 20, larger.graphics_heap >> 20);
|
||||
if (GetSettings().time_step) {
|
||||
const char* value = std::getenv("SHADPS4_TITLE_SOCCER_TIMING");
|
||||
if (value == nullptr || std::atoi(value) != 0) {
|
||||
const auto changes = Builds::SoccerTimingChanges(*build);
|
||||
if (changes.empty()) {
|
||||
LOG_WARNING(Core, "Soccer animation timing fix is not verified for {}", build->name);
|
||||
} else if (const auto* unexpected = Builds::Apply(image, changes)) {
|
||||
LOG_WARNING(Core, "Soccer animation timing fix refused: unexpected bytes at {:#x}",
|
||||
unexpected->at);
|
||||
} else {
|
||||
LOG_INFO(Core, "Soccer animation budgets use console 60-FPS units; rendering "
|
||||
"and elapsed-time integration remain variable-rate");
|
||||
}
|
||||
}
|
||||
}
|
||||
known_base = base;
|
||||
known_build.store(build, std::memory_order_release);
|
||||
}
|
||||
|
||||
void NoteView(const Vr::Vec3& tracker_head) {
|
||||
static constexpr auto Interval = std::chrono::seconds{10};
|
||||
|
||||
const VAddr base = KnownBase();
|
||||
if (base == 0) {
|
||||
const Build* const build = known_build.load(std::memory_order_acquire);
|
||||
if (build == nullptr) {
|
||||
return;
|
||||
}
|
||||
const VAddr base = known_base;
|
||||
static std::mutex mutex;
|
||||
static Vr::Vec3 last_origin;
|
||||
static Clock::time_point last_report;
|
||||
@@ -847,7 +993,7 @@ void NoteView(const Vr::Vec3& tracker_head) {
|
||||
if (!lock.owns_lock()) {
|
||||
return;
|
||||
}
|
||||
const u64 manager = Read<u64>(base + ManagerPointer);
|
||||
const u64 manager = Read<u64>(base + build->manager_pointer);
|
||||
if (manager == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "common/types.h"
|
||||
|
||||
namespace Core::Vr {
|
||||
@@ -10,7 +12,8 @@ struct Vec3;
|
||||
}
|
||||
|
||||
/// What is known about the inner workings of particular titles, and what the emulator does with
|
||||
/// it. Everything here applies to one exact build of one title and does nothing for any other.
|
||||
/// it. Everything here applies to exact builds of one title, told apart by what their images
|
||||
/// hold when loaded (known_title_builds.h), and does nothing for any other title or build.
|
||||
///
|
||||
/// Astro Bot Rescue Mission (CUSA12392):
|
||||
/// - It advances its world by one sixtieth of a second for every frame it draws, however long
|
||||
@@ -21,6 +24,10 @@ struct Vec3;
|
||||
/// frame, with what frames really take: the game then runs by the clock.
|
||||
/// - Its tracking manager keeps the point it counts head positions from, and the position
|
||||
/// counted from it, which is what a view that is off shows up in.
|
||||
/// - It moves the collisions of what moves by a speed worked out with one frame's time step
|
||||
/// and applied for the next frame's: the same thing on the console, not with a time step
|
||||
/// that follows the frames. Its physics are changed to step with the time step the bodies
|
||||
/// were moved for (known_title_builds.h, PhysicsStepChanges).
|
||||
namespace Core::KnownTitle {
|
||||
|
||||
/// Called when the title has handed in a frame (sceGnmSubmitDone): sets the title's time step
|
||||
@@ -38,17 +45,27 @@ 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);
|
||||
|
||||
/// Called when the title reads its controller, which it does once a frame on the thread it
|
||||
/// runs on. SHADPS4_TITLE_PHYSICS_WATCH=1 has the log tell whether the collisions the title
|
||||
/// moves arrive where it sends them.
|
||||
void OnControllerRead();
|
||||
|
||||
/// Called once the title is known and before its memory is set up. A title that is to draw
|
||||
/// larger pictures than on the console (SHADPS4_TITLE_EYE_WIDTH=<pixels>, the width of the
|
||||
/// largest, 1440 on the console) needs more memory than the console has.
|
||||
void Prepare();
|
||||
|
||||
/// Called when the title's executable is in memory, before any of it runs: the sizes the title
|
||||
/// draws at, and the memory it sets aside for that, are written into it where they are larger.
|
||||
/// draws at, and the memory it sets aside for that, are written into it where they are larger,
|
||||
/// and its physics are made to take each step with the time step its bodies were moved for
|
||||
/// (SHADPS4_TITLE_PHYSICS_STEP=0 leaves them as the console has them).
|
||||
void OnGameLoaded(VAddr base, u64 size);
|
||||
|
||||
} // namespace Core::KnownTitle
|
||||
@@ -0,0 +1,346 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "common/types.h"
|
||||
|
||||
/// Where things are in the builds of ASTRO BOT Rescue Mission (CUSA12392/CUSA12431) known from
|
||||
/// inside, and how a build is told from every other. Kept apart from what is done with it
|
||||
/// (known_title.cpp) so that it can be tried without an emulator around it
|
||||
/// (tools/tests/known_title_builds_test.cpp).
|
||||
///
|
||||
/// The game's updates moved its code and data about, each part by an amount of its own: every
|
||||
/// address below was found in the build it is given for, none is worked out from another's.
|
||||
/// (Those of the update 1.04 were found and tried by Clodo76, issue #1.)
|
||||
/// The US 1.00 and 1.04 executables were checked against these same byte signatures.
|
||||
namespace Core::KnownTitle::Builds {
|
||||
|
||||
constexpr s32 FirstHeadsetLevel = 3;
|
||||
constexpr s32 LastHeadsetLevel = 6;
|
||||
// The sizes the title draws its scene at, an eye, as the console has them: the first three are
|
||||
// for a television, the others for the headset.
|
||||
constexpr std::array<std::array<u32, 2>, 7> ConsoleSizes{{
|
||||
{640, 360}, {1280, 720}, {1920, 1080}, {816, 870}, {960, 1080}, {1200, 1280}, {1440, 1536}}};
|
||||
// What the scene's targets are taken from: a pool of 200 MB, a smaller one of 10 MB for what
|
||||
// goes with them, and the heap of graphics memory both come from, 872 MB, which the title takes
|
||||
// from the console's memory at its start.
|
||||
constexpr u32 ConsoleTargetPool = 0xc800000;
|
||||
constexpr u32 ConsoleSmallPool = 0xa00000;
|
||||
constexpr u64 ConsoleGraphicsHeap = 0x36800000;
|
||||
// The time one frame stands for, as the title's engine has it before the title runs.
|
||||
constexpr double ConsoleFrameRate = 60.0;
|
||||
constexpr float ConsoleFrameSeconds = 1.0f / 60.0f;
|
||||
constexpr u64 ConsoleFrameMicroseconds = 16666;
|
||||
|
||||
// The function of the title's tracking manager that asks for the head's position to be taken
|
||||
// anew: it reads the same in every build, and is somewhere else in each.
|
||||
constexpr std::array<u8, 9> SetRecentreCode{0x40, 0x0f, 0xb6, 0xc6, 0xff, 0xc0, 0x89, 0x07, 0xc3};
|
||||
|
||||
/// One place of the title's image that is to hold something else: what the console's build has
|
||||
/// there and what it is to have, in so many bytes.
|
||||
struct Change {
|
||||
u64 at;
|
||||
u64 was;
|
||||
u64 now;
|
||||
u32 bytes;
|
||||
};
|
||||
|
||||
/// The title's world of rigid bodies (Sony's PhysicsEffects under a class of its own), as far
|
||||
/// as the emulator looks into it. The same in every build known.
|
||||
namespace Physics {
|
||||
// From the game's main object to the bodies: the world, what it wraps, the library's world.
|
||||
constexpr u64 GameWorld = 0x10;
|
||||
constexpr u64 WorldInner = 0x8;
|
||||
constexpr u64 InnerScale = 0x58; // float: the library's lengths for one of the game's
|
||||
constexpr u64 InnerLibrary = 0x20;
|
||||
constexpr u64 LibraryBodyCount = 0x1a0; // u32
|
||||
constexpr u64 LibraryBodies = 0x1a8; // pointer
|
||||
constexpr u64 LibraryTimeStep = 0x2760; // float
|
||||
// One body's state.
|
||||
constexpr u64 BodySize = 0xa0;
|
||||
constexpr u64 BodyMotion = 0x1; // u8
|
||||
constexpr u64 BodySegment = 0x30; // s32 [3]
|
||||
constexpr u64 BodyPosition = 0x40; // float [3]
|
||||
constexpr u64 BodyVelocity = 0x60; // float [3]
|
||||
// A body that is moved by hand: sent to a place, which it reaches with the next step.
|
||||
constexpr u8 MotionKeyframe = 2;
|
||||
} // namespace Physics
|
||||
|
||||
struct Build {
|
||||
/// What the log calls it.
|
||||
const char* name;
|
||||
u64 set_recentre;
|
||||
/// The tracking manager, a singleton: where the pointer to it is kept.
|
||||
u64 manager_pointer;
|
||||
/// The engine's frame rate and what it derives from it when the rate is set: the time one
|
||||
/// frame stands for, in seconds and in microseconds. Everything in the game that moves
|
||||
/// reads one of the latter two.
|
||||
u64 frame_rate; // double
|
||||
u64 frame_seconds; // float
|
||||
u64 frame_microseconds; // u64
|
||||
/// What sets the size the scene is drawn at, a singleton made when first asked for: where
|
||||
/// the pointer to it is kept.
|
||||
u64 resolution_pointer;
|
||||
/// Where the sizes are written down: a table of widths, one of heights, the headset's once
|
||||
/// more in the switch of the function that makes the scene's targets (heights, then
|
||||
/// widths), and a table of pixel counts the title's own choice goes by.
|
||||
u64 size_widths; // u32 [7]
|
||||
u64 size_heights; // u32 [7]
|
||||
std::array<std::array<u64, 2>, 4> size_switch;
|
||||
u64 size_pixels; // u64, 32 bytes apart
|
||||
/// The pictures handed to the headset, made three pairs at a time: their width and height.
|
||||
std::array<u64, 4> eye_sizes;
|
||||
/// The sizes of the two pools (in two places and in three) and of the heap.
|
||||
std::array<u64, 2> target_pool;
|
||||
std::array<u64, 3> small_pool;
|
||||
u64 graphics_heap; // u64
|
||||
/// The game's main object, a singleton: where the pointer to it is kept. It knows the
|
||||
/// world of rigid bodies (namespace Physics).
|
||||
u64 game_pointer;
|
||||
/// The end of the function that has that world take a step (PhysicsStepChanges); places
|
||||
/// of no bytes fill the list up.
|
||||
std::array<Change, 4> physics_step;
|
||||
/// Console-frame units used by the soccer enemy's animation budget. Zero where the
|
||||
/// corresponding instructions have not been verified in that game build.
|
||||
u64 soccer_nominal_seconds{};
|
||||
std::array<u64, 2> soccer_budget_reads{};
|
||||
};
|
||||
|
||||
inline constexpr std::array<Build, 2> Known{{
|
||||
{
|
||||
.name = "1.00, as on the disc",
|
||||
.set_recentre = 0xc48320,
|
||||
.manager_pointer = 0x2e025a8,
|
||||
.frame_rate = 0x16688a8,
|
||||
.frame_seconds = 0x16688b0,
|
||||
.frame_microseconds = 0x16688b8,
|
||||
.resolution_pointer = 0x2dff9e0,
|
||||
.size_widths = 0x12da900,
|
||||
.size_heights = 0x12da920,
|
||||
.size_switch = {{{0xf2242f, 0xf22435},
|
||||
{0xf2240d, 0xf22413},
|
||||
{0xf22551, 0xf22557},
|
||||
{0xf2255f, 0xf22565}}},
|
||||
.size_pixels = 0x1645048,
|
||||
.eye_sizes = {0xc3fad0, 0xc3fad5, 0xc3fb5c, 0xc3fb61},
|
||||
.target_pool = {0xef8bf7, 0xef8c4d},
|
||||
.small_pool = {0xf22194, 0xf221be, 0xf221dd},
|
||||
.graphics_heap = 0x1269708,
|
||||
.game_pointer = 0x2dff9b8,
|
||||
// mov rdi, [rdi+0x20]; vmovss [rdi+0x2760], xmm0; jmp <the library's step>; (padding)
|
||||
// becomes
|
||||
// mov rdi, [rdi+0x20]; vmovd eax, xmm0; push rax; push rdi; push rdi; call <step>;
|
||||
// pop rdi; pop rdi; pop rax; mov [rdi+0x2760], eax; ret
|
||||
.physics_step = {{{0xc2dea0, 0x8711fac5207f8b48, 0xc07ef9c5207f8b48, 8},
|
||||
{0xc2dea8, 0x0449bfe900002760, 0x000449c0e8575750, 8},
|
||||
{0xc2deb0, 0x9090909090909000, 0x0027608789585f5f, 8},
|
||||
{0xc2deb8, 0x9090909090909090, 0x909090909090c300, 8}}},
|
||||
.soccer_nominal_seconds = 0x10d1694,
|
||||
.soccer_budget_reads = {0x843133, 0x8438ea},
|
||||
},
|
||||
{
|
||||
.name = "1.04, the last update",
|
||||
.set_recentre = 0xccc0b0,
|
||||
.manager_pointer = 0x2ed2ae8,
|
||||
.frame_rate = 0x17208b8,
|
||||
.frame_seconds = 0x17208c0,
|
||||
.frame_microseconds = 0x17208c8,
|
||||
.resolution_pointer = 0x2ecfe60,
|
||||
.size_widths = 0x1371120,
|
||||
.size_heights = 0x1371140,
|
||||
.size_switch = {{{0xfa5a1f, 0xfa5a25},
|
||||
{0xfa59fd, 0xfa5a03},
|
||||
{0xfa5b41, 0xfa5b47},
|
||||
{0xfa5b4f, 0xfa5b55}}},
|
||||
.size_pixels = 0x16fb558,
|
||||
.eye_sizes = {0xcc3580, 0xcc3585, 0xcc360c, 0xcc3611},
|
||||
.target_pool = {0xf7c117, 0xf7c16d},
|
||||
.small_pool = {0xfa5784, 0xfa57ae, 0xfa57cd},
|
||||
.graphics_heap = 0x12ffb78,
|
||||
.game_pointer = 0x2ecfe38,
|
||||
// (mov rdi, [r15+0x20];) vmovss xmm0, [rbp-0x74]; vmovss [rdi+0x2760], xmm0;
|
||||
// call <the library's step>
|
||||
// becomes
|
||||
// (mov rdi, [r15+0x20];) call <step>; mov eax, [rbp-0x74]; mov rdi, [r15+0x20];
|
||||
// mov [rdi+0x2760], eax
|
||||
.physics_step = {{{0xcb09cf, 0x11fac58c4510fac5, 0x8c458b00045cfce8, 8},
|
||||
{0xcb09d7, 0x5cefe80000276087, 0x27608789207f8b49, 8},
|
||||
{0xcb09df, 0x0004, 0x0000, 2},
|
||||
{0, 0, 0, 0}}},
|
||||
},
|
||||
}};
|
||||
|
||||
/// The sizes of a title that draws larger than on the console, and the memory that takes.
|
||||
struct Sizes {
|
||||
std::array<std::array<u32, 2>, 7> sizes{ConsoleSizes};
|
||||
u32 target_pool{ConsoleTargetPool};
|
||||
u32 small_pool{ConsoleSmallPool};
|
||||
u64 graphics_heap{ConsoleGraphicsHeap};
|
||||
};
|
||||
|
||||
/// Every place that sets the sizes of a build, with what the console has there and what
|
||||
/// `sizes` wants there.
|
||||
inline std::vector<Change> SizeChanges(const Build& build, const Sizes& sizes) {
|
||||
std::vector<Change> changes;
|
||||
for (s32 level = FirstHeadsetLevel; level <= LastHeadsetLevel; ++level) {
|
||||
const auto& was = ConsoleSizes[level];
|
||||
const auto& now = sizes.sizes[level];
|
||||
changes.push_back({build.size_widths + 4 * level, was[0], now[0], 4});
|
||||
changes.push_back({build.size_heights + 4 * level, was[1], now[1], 4});
|
||||
changes.push_back({build.size_switch[level - FirstHeadsetLevel][0], was[1], now[1], 4});
|
||||
changes.push_back({build.size_switch[level - FirstHeadsetLevel][1], was[0], now[0], 4});
|
||||
changes.push_back(
|
||||
{build.size_pixels + 32 * level, u64{was[0]} * was[1], u64{now[0]} * now[1], 8});
|
||||
}
|
||||
const auto& eye_was = ConsoleSizes[LastHeadsetLevel];
|
||||
const auto& eye_now = sizes.sizes[LastHeadsetLevel];
|
||||
for (u32 i = 0; i < build.eye_sizes.size(); ++i) {
|
||||
changes.push_back({build.eye_sizes[i], eye_was[i % 2], eye_now[i % 2], 4});
|
||||
}
|
||||
for (const u64 at : build.target_pool) {
|
||||
changes.push_back({at, ConsoleTargetPool, sizes.target_pool, 4});
|
||||
}
|
||||
for (const u64 at : build.small_pool) {
|
||||
changes.push_back({at, ConsoleSmallPool, sizes.small_pool, 4});
|
||||
}
|
||||
changes.push_back({build.graphics_heap, ConsoleGraphicsHeap, sizes.graphics_heap, 8});
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// What makes the title's collisions land where they are sent when its time step is not the
|
||||
/// same from one frame to the next.
|
||||
///
|
||||
/// The title moves the collision bodies of what moves by hand (platforms, doors, a part of a
|
||||
/// level that rises) by sending them to a place: the library gives the body the speed that
|
||||
/// takes it there in one step, speed = distance / time step, and the step that follows moves
|
||||
/// it by speed x time step. The time step it divides by is the one of the step last taken; the
|
||||
/// one the body is then moved by is the next frame's. On the console both are a sixtieth of a
|
||||
/// second and the body arrives. With a time step that follows what frames take
|
||||
/// (KnownTitle::OnFrameSubmitted) the body stops short or goes too far, by as much of its way
|
||||
/// as the two steps differ. For what moves all the time that is put right a frame later, and
|
||||
/// never seen. For what is sent once it stays: the parts of a level the title leaves alone
|
||||
/// until the player gets near (its sections) are sent, all at once and in the one frame that
|
||||
/// wakes them, from where they were to where the level has moved since. At the end of level
|
||||
/// 2-1 that is 17 units up a tree that grew while they slept, and the frame that wakes them
|
||||
/// is a long one: the mound's collisions ended most of a block above the mound (issue #16).
|
||||
///
|
||||
/// The change: the function that has the library take a step wrote down the new time step
|
||||
/// and then took the step. It now takes the step with the time step that is written down, the
|
||||
/// one every body was sent with, and writes the new one down after.
|
||||
inline std::vector<Change> PhysicsStepChanges(const Build& build) {
|
||||
std::vector<Change> changes;
|
||||
for (const Change& change : build.physics_step) {
|
||||
if (change.bytes != 0) {
|
||||
changes.push_back(change);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// The soccer enemy compares an animation's duration in seconds against tuning expressed
|
||||
/// in console frames. Its two budget conversions used the current update step: at 72 FPS
|
||||
/// a budget intended for 60 FPS shrinks to 5/6 and the check at MupSoccerEnemy.cpp:1650
|
||||
/// can assert. Read the immutable console frame unit for those conversions only. Elapsed
|
||||
/// time integration, animation playback, the null-resource branch and assertion remain.
|
||||
/// Verified for 1.00; do not infer 1.04 code locations from the other build.
|
||||
inline std::vector<Change> SoccerTimingChanges(const Build& build) {
|
||||
if (build.soccer_nominal_seconds == 0) {
|
||||
return {};
|
||||
}
|
||||
std::vector<Change> changes{
|
||||
{build.soccer_nominal_seconds, 0x3c888889, 0x3c888889, 4},
|
||||
};
|
||||
for (const u64 at : build.soccer_budget_reads) {
|
||||
// vmovss xmm0, [rbp-0x2598] -> vmovss xmm0, [rip+console_frame_seconds]
|
||||
const u64 displacement = static_cast<u32>(build.soccer_nominal_seconds - (at + 8));
|
||||
changes.push_back({at, 0xffffda688510fac5, (displacement << 32) | 0x0510fac5, 8});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// The first of some places that does not hold what the console's build has there (or lies
|
||||
/// outside the image), nullptr when all do.
|
||||
inline const Change* FirstUnexpected(std::span<const u8> image, std::span<const Change> changes) {
|
||||
for (const Change& change : changes) {
|
||||
if (change.bytes > sizeof(u64) || change.at > image.size() ||
|
||||
image.size() - change.at < change.bytes) {
|
||||
return &change;
|
||||
}
|
||||
u64 found = 0;
|
||||
std::memcpy(&found, image.data() + change.at, change.bytes);
|
||||
if (found != change.was) {
|
||||
return &change;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/// Whether an image, as loaded and before any of it has run, is this build: the function that
|
||||
/// tells builds apart is where it is in this one, the engine's time step is where this one
|
||||
/// keeps it, and so is every size. (After the title has run, or once its sizes have been
|
||||
/// changed, this is no longer true of the very same image: ask once.)
|
||||
inline bool Is(const Build& build, std::span<const u8> image) {
|
||||
const auto holds = [&](u64 at, const void* bytes, size_t size) {
|
||||
return at <= image.size() && image.size() - at >= size &&
|
||||
std::memcmp(image.data() + at, bytes, size) == 0;
|
||||
};
|
||||
// (The two singletons are made while the title runs: their pointers only have to be
|
||||
// inside the image.)
|
||||
for (const u64 pointer : {build.manager_pointer, build.resolution_pointer}) {
|
||||
if (pointer > image.size() || image.size() - pointer < sizeof(u64)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!holds(build.set_recentre, SetRecentreCode.data(), SetRecentreCode.size()) ||
|
||||
!holds(build.frame_rate, &ConsoleFrameRate, sizeof(ConsoleFrameRate)) ||
|
||||
!holds(build.frame_seconds, &ConsoleFrameSeconds, sizeof(ConsoleFrameSeconds)) ||
|
||||
!holds(build.frame_microseconds, &ConsoleFrameMicroseconds,
|
||||
sizeof(ConsoleFrameMicroseconds))) {
|
||||
return false;
|
||||
}
|
||||
for (u32 level = 0; level < ConsoleSizes.size(); ++level) {
|
||||
if (!holds(build.size_widths + 4 * level, &ConsoleSizes[level][0], sizeof(u32)) ||
|
||||
!holds(build.size_heights + 4 * level, &ConsoleSizes[level][1], sizeof(u32))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Every place a larger picture is written to, as the console has it.
|
||||
return FirstUnexpected(image, SizeChanges(build, Sizes{})) == nullptr;
|
||||
}
|
||||
|
||||
/// The build an image is, as loaded and before any of it has run; nullptr for one that is none
|
||||
/// of those known (or would be more than one of them, which no build of the title is).
|
||||
inline const Build* Recognise(std::span<const u8> image) {
|
||||
const Build* found = nullptr;
|
||||
for (const Build& build : Known) {
|
||||
if (!Is(build, image)) {
|
||||
continue;
|
||||
}
|
||||
if (found != nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
found = &build;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Writes changes into an image, all of them or none: nothing is written unless every place
|
||||
/// holds what the console's build has there. Answers with the place that did not, nullptr
|
||||
/// when all was written.
|
||||
inline const Change* Apply(std::span<u8> image, std::span<const Change> changes) {
|
||||
if (const Change* unexpected = FirstUnexpected(image, changes); unexpected != nullptr) {
|
||||
return unexpected;
|
||||
}
|
||||
for (const Change& change : changes) {
|
||||
std::memcpy(image.data() + change.at, &change.now, change.bytes);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace Core::KnownTitle::Builds
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -100,6 +101,82 @@ static void ApplyScriptedLevel(const PortIn& port, s16* samples, int frames) {
|
||||
}
|
||||
}
|
||||
|
||||
/// SHADPS4_MIC_GAIN=<0.1..30>: what the microphone gives, that many times louder. A title
|
||||
/// that takes loudness for blowing takes none for it from a microphone that is too quiet.
|
||||
static void ApplyGain(const PortIn& port, s16* samples, int frames) {
|
||||
static const float gain = [] {
|
||||
const char* value = std::getenv("SHADPS4_MIC_GAIN");
|
||||
const float wanted = value != nullptr ? static_cast<float>(std::atof(value)) : 1.0f;
|
||||
return wanted > 0.0f ? std::clamp(wanted, 0.1f, 30.0f) : 1.0f;
|
||||
}();
|
||||
if (gain == 1.0f) {
|
||||
return;
|
||||
}
|
||||
const u32 count = static_cast<u32>(frames) * port.channels_num;
|
||||
for (u32 i = 0; i < count; ++i) {
|
||||
samples[i] = static_cast<s16>(std::clamp(samples[i] * gain, -32768.0f, 32767.0f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Says now and then how loud what a title was given is, when that is worth knowing: the first
|
||||
/// time, whenever something loud was heard, and once when there has been nothing but silence,
|
||||
/// which is what a microphone that may not be used gives (and one whose owner keeps quiet, if
|
||||
/// it cuts out what is not speech). (The loudness is all ASTRO BOT goes by: it
|
||||
/// takes -21 dB for blowing at half strength, -9 dB for blowing as hard as can be.)
|
||||
static void NoteLoudness(const PortIn& port, const s16* samples, int frames) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static std::mutex mutex;
|
||||
const std::scoped_lock lock{mutex};
|
||||
static Clock::time_point since{};
|
||||
static Clock::time_point started{};
|
||||
static double loudest = 0.0;
|
||||
static bool any_sound = false;
|
||||
static bool first = true;
|
||||
static bool silence_said = false;
|
||||
const u32 count = static_cast<u32>(frames) * port.channels_num;
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
double squares = 0.0;
|
||||
for (u32 i = 0; i < count; ++i) {
|
||||
const double sample = samples[i] / 32768.0;
|
||||
squares += sample * sample;
|
||||
any_sound = any_sound || samples[i] != 0;
|
||||
}
|
||||
loudest = std::max(loudest, std::sqrt(squares / count));
|
||||
const auto now = Clock::now();
|
||||
if (since == Clock::time_point{}) {
|
||||
since = now;
|
||||
started = now;
|
||||
}
|
||||
if (now - since < std::chrono::seconds{10}) {
|
||||
return;
|
||||
}
|
||||
// (Not for a microphone that has given nothing at all: that is what the line below is for.)
|
||||
if ((first && any_sound) || Decibels(loudest) > -30.0) {
|
||||
LOG_INFO(Lib_AudioIn,
|
||||
"Microphone: the loudest of the last 10 seconds was {:.0f} dB (the game takes "
|
||||
"-21 dB for blowing at half strength, -9 dB for blowing as hard as can be)",
|
||||
Decibels(loudest));
|
||||
first = false;
|
||||
}
|
||||
if (!any_sound && !silence_said && now - started > std::chrono::seconds{30}) {
|
||||
silence_said = true;
|
||||
#ifdef _WIN32
|
||||
LOG_INFO(Lib_AudioIn,
|
||||
"Microphone: nothing but silence has come from it so far. If blowing does "
|
||||
"nothing in the game: is it the microphone Windows records from, may desktop "
|
||||
"apps use the microphone (Windows settings, Privacy & security, Microphone), "
|
||||
"and may the headset's streaming app use the headset's? The PS button and "
|
||||
"square held together blow without one (X and Y on a headset's controllers)");
|
||||
#else
|
||||
LOG_INFO(Lib_AudioIn, "Microphone: nothing but silence has come from it so far");
|
||||
#endif
|
||||
}
|
||||
since = now;
|
||||
loudest = 0.0;
|
||||
}
|
||||
|
||||
static void NoteInput(int port_id, const PortIn& port, const s16* samples, int frames) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
InputStats& stats = input_stats[port_id];
|
||||
@@ -316,7 +393,9 @@ int PS4_SYSV_ABI sceAudioInInput(s32 handle, void* dest) {
|
||||
std::scoped_lock lock{port->mutex};
|
||||
const int frames = port->impl->Read(dest);
|
||||
if (frames > 0) {
|
||||
ApplyGain(*port, static_cast<s16*>(dest), frames);
|
||||
ApplyScriptedLevel(*port, static_cast<s16*>(dest), frames);
|
||||
NoteLoudness(*port, static_cast<const s16*>(dest), frames);
|
||||
if (StatsEnabled()) {
|
||||
NoteInput(port_id, *port, static_cast<const s16*>(dest), frames);
|
||||
}
|
||||
|
||||
@@ -81,10 +81,38 @@ static bool SDLCALL LogDeviceEvent(void*, SDL_Event* event) {
|
||||
event->adevice.recording ? "input" : "output",
|
||||
event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ? "added" : "removed",
|
||||
name ? name : "?", id);
|
||||
#ifdef ENABLE_OPENXR_HOST
|
||||
// Which device is the headset's may have changed with that: a runtime that streams to
|
||||
// a headset has its devices only while it does.
|
||||
Core::Vr::OpenXrHost::Instance().AudioDevicesChanged();
|
||||
#endif
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The device the system plays on by default, as the device it is. Never "the default device"
|
||||
/// itself: ports opened on that are moved by SDL when the system's choice changes, and SDL
|
||||
/// (3.5.0) leaves the list it moves them into in a state in which closing one of them later
|
||||
/// frees memory its playback thread still reads (SDL_DefaultAudioDeviceChanged does not set
|
||||
/// the back link of the port it puts in front): the emulator stopped with an access violation
|
||||
/// in SDL_GetAudioStreamDataAdjustGain a moment after a port left such a device. A port
|
||||
/// opened on a device of its own stays where it is, and FollowDevice moves it.
|
||||
static SDL_AudioDeviceID DefaultPlaybackDevice() {
|
||||
const char* default_name = SDL_GetAudioDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK);
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID* devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
SDL_AudioDeviceID found = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
for (int i = 0; default_name != nullptr && devices != nullptr && i < count; ++i) {
|
||||
const char* name = SDL_GetAudioDeviceName(devices[i]);
|
||||
if (name != nullptr && std::strcmp(name, default_name) == 0) {
|
||||
found = devices[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
SDL_free(devices);
|
||||
return found;
|
||||
}
|
||||
|
||||
class SDLPortBackend : public PortBackend {
|
||||
public:
|
||||
explicit SDLPortBackend(const PortOut& port)
|
||||
@@ -242,7 +270,8 @@ private:
|
||||
if (device_name == "None") {
|
||||
return;
|
||||
}
|
||||
const SDL_AudioDeviceID dev_id = SelectAudioDevice(device_name, true);
|
||||
const Choice choice = SelectAudioDevice(device_name, true);
|
||||
const SDL_AudioDeviceID dev_id = choice.device;
|
||||
if (stream && dev_id == opened_device) {
|
||||
pending_device = SDL_INVALID_AUDIODEVICEID;
|
||||
return;
|
||||
@@ -261,10 +290,15 @@ private:
|
||||
static std::mutex moving;
|
||||
std::scoped_lock one{moving};
|
||||
if (stream) {
|
||||
if (dev_id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) {
|
||||
const char* name = SDL_GetAudioDeviceName(dev_id);
|
||||
if (choice.stands_in) {
|
||||
LOG_WARNING(Lib_AudioOut,
|
||||
"Audio device '{}' went away: the sound moves to the system's default",
|
||||
device_name);
|
||||
"Audio device '{}' went away: the sound moves to the system's "
|
||||
"default, '{}'",
|
||||
device_name, name ? name : "?");
|
||||
} else if (choice.systems) {
|
||||
LOG_INFO(Lib_AudioOut, "The system now plays on '{}': the sound moves to it",
|
||||
name ? name : "?");
|
||||
} else {
|
||||
LOG_INFO(Lib_AudioOut, "Audio device '{}' is there: the sound moves to it",
|
||||
device_name);
|
||||
@@ -371,7 +405,8 @@ private:
|
||||
|
||||
// Determine device
|
||||
const std::string device_name = GetDeviceName();
|
||||
const SDL_AudioDeviceID dev_id = SelectAudioDevice(device_name, false);
|
||||
const Choice choice = SelectAudioDevice(device_name, false);
|
||||
const SDL_AudioDeviceID dev_id = choice.device;
|
||||
|
||||
if (dev_id == SDL_INVALID_AUDIODEVICEID) {
|
||||
return false;
|
||||
@@ -406,28 +441,34 @@ private:
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* default_name = SDL_GetAudioDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK);
|
||||
const bool stands_in = dev_id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
|
||||
!device_name.empty() && device_name != "Default Device" &&
|
||||
(default_name == nullptr || device_name != default_name);
|
||||
LOG_INFO(Lib_AudioOut,
|
||||
"Opened audio device: {} ({} Hz, {} ch{}, gain: {:.3f})",
|
||||
stands_in ? "the system's default" : device_name, sample_rate, num_channels,
|
||||
const char* opened_name = SDL_GetAudioDeviceName(dev_id);
|
||||
LOG_INFO(Lib_AudioOut, "Opened audio device: {}{} ({} Hz, {} ch{}, gain: {:.3f})",
|
||||
opened_name ? opened_name : "?",
|
||||
choice.stands_in ? ", the system's default" : "", sample_rate, num_channels,
|
||||
virtualizer ? " rendered for two speakers at the ears" : "", initial_gain);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The device a port is to play on. `systems`: it is the one the system plays on by
|
||||
/// default, because the port is left to the system; `stands_in`: or because the device the
|
||||
/// port is meant for is not there.
|
||||
struct Choice {
|
||||
SDL_AudioDeviceID device{SDL_INVALID_AUDIODEVICEID};
|
||||
bool systems{};
|
||||
bool stands_in{};
|
||||
};
|
||||
|
||||
/// The device of that name, or the system's default when there is none (said in the log
|
||||
/// unless `quiet`).
|
||||
SDL_AudioDeviceID SelectAudioDevice(const std::string& device_name, bool quiet) {
|
||||
Choice SelectAudioDevice(const std::string& device_name, bool quiet) {
|
||||
if (device_name == "None") {
|
||||
LOG_INFO(Lib_AudioOut, "Audio device disabled for port type {}",
|
||||
static_cast<int>(port_type));
|
||||
return SDL_INVALID_AUDIODEVICEID;
|
||||
return {};
|
||||
}
|
||||
|
||||
if (device_name.empty() || device_name == "Default Device") {
|
||||
return SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
return {.device = DefaultPlaybackDevice(), .systems = true};
|
||||
}
|
||||
|
||||
// Search for specific device
|
||||
@@ -438,7 +479,7 @@ private:
|
||||
if (!quiet) {
|
||||
LOG_WARNING(Lib_AudioOut, "No audio devices found, using default");
|
||||
}
|
||||
return SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
return {.device = DefaultPlaybackDevice(), .stands_in = true};
|
||||
}
|
||||
|
||||
SDL_AudioDeviceID selected_device = SDL_INVALID_AUDIODEVICEID;
|
||||
@@ -458,17 +499,9 @@ private:
|
||||
LOG_WARNING(Lib_AudioOut, "Audio device '{}' not found, using default",
|
||||
device_name);
|
||||
}
|
||||
return SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
return {.device = DefaultPlaybackDevice(), .stands_in = true};
|
||||
}
|
||||
|
||||
// Where it is the system's default, the port plays on that: SDL moves such a stream
|
||||
// by itself when the default changes, which is what happens when the device goes
|
||||
// away and when it comes back. (Opening the device anew in those moments is not safe.)
|
||||
const char* default_name = SDL_GetAudioDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK);
|
||||
if (default_name != nullptr && device_name == default_name) {
|
||||
return SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
}
|
||||
return selected_device;
|
||||
return {.device = selected_device};
|
||||
}
|
||||
|
||||
bool ConfigureChannelMap() {
|
||||
|
||||
@@ -239,6 +239,12 @@ s32 PS4_SYSV_ABI sceHmdReprojectionStart(const OrbisHmdReprojectionParam* param,
|
||||
frame_number, flip_arg, left.width + 1, left.height + 1, left.Address(),
|
||||
frame.eye_textures[1].Address(), frame.fov.tan_out, frame.fov.tan_in,
|
||||
frame.fov.tan_top, frame.fov.tan_bottom, param->unknown_40, param->flags, option);
|
||||
LOG_INFO(Lib_Hmd,
|
||||
"eye UV: left scale {:.6f}/{:.6f}, offset {:.6f}/{:.6f}; "
|
||||
"right scale {:.6f}/{:.6f}, offset {:.6f}/{:.6f}",
|
||||
param->uv[0].scale_x, param->uv[0].scale_y, param->uv[0].offset_x,
|
||||
param->uv[0].offset_y, param->uv[1].scale_x, param->uv[1].scale_y,
|
||||
param->uv[1].offset_x, param->uv[1].offset_y);
|
||||
}
|
||||
|
||||
Core::GuestCpu::NoteGuestProgress();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024-2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#include <magic_enum/magic_enum.hpp>
|
||||
#include "common/precise_timer_scheduler.h"
|
||||
|
||||
#include "common/assert.h"
|
||||
#include "common/debug.h"
|
||||
@@ -24,6 +27,69 @@ extern void KernelSignalRequest();
|
||||
static std::unordered_map<s32, EqueueInternal*> kqueues;
|
||||
static constexpr auto HrTimerSpinlockThresholdNs = 1200000u;
|
||||
|
||||
namespace {
|
||||
Common::PreciseTimerScheduler& GuestTimerScheduler() {
|
||||
static Common::PreciseTimerScheduler scheduler{[] {
|
||||
const char* value = std::getenv("SHADPS4_TIMER_SPIN_US");
|
||||
return std::chrono::microseconds{value ? std::clamp(std::atoi(value), 0, 5000) : 300};
|
||||
}()};
|
||||
return scheduler;
|
||||
}
|
||||
std::atomic<u64> precise_serials{0};
|
||||
} // namespace
|
||||
|
||||
EqueueInternal::~EqueueInternal() {
|
||||
// A callback holds this lock for the complete access to its owner. Deletion waits for it.
|
||||
std::scoped_lock lock{m_precise_lifetime->mutex};
|
||||
m_precise_lifetime->owner = nullptr;
|
||||
}
|
||||
|
||||
bool EqueueInternal::SchedulePreciseTimer(u64 id) {
|
||||
auto& scheduler = GuestTimerScheduler();
|
||||
if (!scheduler.Available())
|
||||
return false;
|
||||
std::chrono::steady_clock::time_point deadline;
|
||||
u64 serial;
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
const auto it = std::ranges::find_if(m_events, [id](const auto& event) {
|
||||
return event.event.ident == id &&
|
||||
event.event.filter == OrbisKernelEvent::Filter::HrTimer;
|
||||
});
|
||||
if (it == m_events.end())
|
||||
return false;
|
||||
serial = it->precise_serial = precise_serials.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
deadline = it->time_added + it->timer_interval;
|
||||
}
|
||||
std::weak_ptr<PreciseLifetime> lifetime = m_precise_lifetime;
|
||||
return scheduler.Add(deadline, [lifetime, id, serial] {
|
||||
if (const auto guard = lifetime.lock()) {
|
||||
std::scoped_lock lock{guard->mutex};
|
||||
if (guard->owner)
|
||||
guard->owner->TriggerPreciseTimer(id, serial);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool EqueueInternal::TriggerPreciseTimer(u64 ident, u64 serial) {
|
||||
bool found = false;
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
for (auto& event : m_events) {
|
||||
if (event.event.ident == ident &&
|
||||
event.event.filter == OrbisKernelEvent::Filter::HrTimer &&
|
||||
event.precise_serial == serial) {
|
||||
event.TriggerTimer();
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found)
|
||||
m_cond.notify_one();
|
||||
return found;
|
||||
}
|
||||
|
||||
EqueueInternal* GetEqueue(OrbisKernelEqueue eq) {
|
||||
if (!kqueues.contains(eq)) {
|
||||
return nullptr;
|
||||
@@ -111,6 +177,16 @@ bool EqueueInternal::AddEvent(EqueueEvent& event) {
|
||||
if (filter == OrbisKernelEvent::Filter::Timer) {
|
||||
return this->ScheduleEvent(id, OrbisKernelEvent::Filter::Timer, TimerCallback);
|
||||
} else if (filter == OrbisKernelEvent::Filter::HrTimer) {
|
||||
static const bool precise = [] {
|
||||
const char* value = std::getenv("SHADPS4_PERF_PRECISE_TIMERS");
|
||||
const bool enabled = value == nullptr || std::string_view{value} != "0";
|
||||
const bool available = enabled && GuestTimerScheduler().Available();
|
||||
LOG_INFO(Kernel_Event, "Precise guest HR timers: {}",
|
||||
available ? "enabled" : "disabled (asio fallback)");
|
||||
return available;
|
||||
}();
|
||||
if (precise && SchedulePreciseTimer(id))
|
||||
return true;
|
||||
return this->ScheduleEvent(id, OrbisKernelEvent::Filter::HrTimer, HrTimerCallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <condition_variable>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -88,6 +89,7 @@ struct EqueueEvent {
|
||||
std::chrono::steady_clock::time_point time_added;
|
||||
std::chrono::nanoseconds timer_interval;
|
||||
std::unique_ptr<boost::asio::steady_timer> timer;
|
||||
u64 precise_serial = 0;
|
||||
|
||||
void Clear() {
|
||||
is_triggered = false;
|
||||
@@ -149,7 +151,10 @@ class EqueueInternal {
|
||||
|
||||
public:
|
||||
explicit EqueueInternal(OrbisKernelEqueue handle, std::string_view name)
|
||||
: m_handle(handle), m_name(name) {}
|
||||
: m_precise_lifetime(std::make_shared<PreciseLifetime>()), m_handle(handle), m_name(name) {
|
||||
m_precise_lifetime->owner = this;
|
||||
}
|
||||
~EqueueInternal();
|
||||
|
||||
std::string_view GetName() const {
|
||||
return m_name;
|
||||
@@ -181,6 +186,13 @@ public:
|
||||
bool EventExists(u64 id, s16 filter);
|
||||
|
||||
private:
|
||||
struct PreciseLifetime {
|
||||
std::mutex mutex;
|
||||
EqueueInternal* owner{};
|
||||
};
|
||||
bool SchedulePreciseTimer(u64 id);
|
||||
bool TriggerPreciseTimer(u64 ident, u64 serial);
|
||||
std::shared_ptr<PreciseLifetime> m_precise_lifetime;
|
||||
OrbisKernelEqueue m_handle;
|
||||
std::string m_name;
|
||||
std::mutex m_mutex;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "common/logging/log.h"
|
||||
#include "common/singleton.h"
|
||||
#include "core/emulator_settings.h"
|
||||
#include "core/known_title.h"
|
||||
#include "core/libraries/libs.h"
|
||||
#include "core/libraries/pad/pad_errors.h"
|
||||
#include "core/user_settings.h"
|
||||
@@ -535,11 +536,36 @@ int ProcessStates(s32 handle, OrbisPadData* pData, Input::GameController& contro
|
||||
pData[i].deviceUniqueDataLen = 0;
|
||||
}
|
||||
|
||||
// SHADPS4_PAD_TOUCH_TRACE=1: what the title is given of the touchpad, read by read, for
|
||||
// as long as a finger is on it or the pad is pressed. A title makes its swipes and pulls
|
||||
// of what it finds at each of its reads (the newest of the states it is handed), which
|
||||
// is not what the finger did but what was seen of it: this says what was.
|
||||
static const bool touch_trace = [] {
|
||||
const char* value = std::getenv("SHADPS4_PAD_TOUCH_TRACE");
|
||||
return value != nullptr && value[0] == '1';
|
||||
}();
|
||||
if (touch_trace && num > 0 && handle == 1) {
|
||||
static bool was_busy = false;
|
||||
static u64 reads = 0;
|
||||
const OrbisPadData& last = pData[num - 1];
|
||||
const bool down = last.touchData.touchNum != 0;
|
||||
const bool pressed = True(last.buttons & OrbisPadButtonDataOffset::TouchPad);
|
||||
++reads;
|
||||
if (down || pressed || was_busy) {
|
||||
LOG_INFO(Lib_Pad, "touch trace: read {} at {} ms, {} states: finger {} at {} {}, pad {}",
|
||||
reads, last.timestamp / 1000, num, down ? "down" : "up",
|
||||
last.touchData.touch[0].x, last.touchData.touch[0].y,
|
||||
pressed ? "pressed" : "not pressed");
|
||||
}
|
||||
was_busy = down || pressed;
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
int PS4_SYSV_ABI scePadRead(s32 handle, OrbisPadData* pData, s32 num) {
|
||||
LOG_TRACE(Lib_Pad, "called");
|
||||
Core::KnownTitle::OnControllerRead();
|
||||
handle = EnsurePadHandle(handle);
|
||||
int connected_count = 0;
|
||||
bool connected = false;
|
||||
@@ -580,6 +606,7 @@ int PS4_SYSV_ABI scePadReadState(s32 handle, OrbisPadData* pData) {
|
||||
LOG_INFO(Lib_Pad, "handle: {}", handle);
|
||||
logged_once = true;
|
||||
}
|
||||
Core::KnownTitle::OnControllerRead();
|
||||
handle = EnsurePadHandle(handle);
|
||||
auto it = handle_to_controller_map.find(handle);
|
||||
if (it == handle_to_controller_map.end()) {
|
||||
|
||||
@@ -516,7 +516,8 @@ void VideoOutDriver::SubmitHmdFrameInternal(VideoOutPort* port, const HmdFrame&
|
||||
u32 eye_width = 0;
|
||||
u32 eye_height = 0;
|
||||
const Vulkan::HmdFrames frames =
|
||||
presenter->PrepareHmdFrame(hmd_frame.eye_textures, frame_id, eye_width, eye_height);
|
||||
presenter->PrepareHmdFrame(hmd_frame.eye_textures, hmd_frame.fov, frame_id, eye_width,
|
||||
eye_height);
|
||||
if (!frames) {
|
||||
// Same as regular flips: retry once the current graphics task yields.
|
||||
liverpool->EnqueueCommand([=, this] {
|
||||
@@ -729,13 +730,19 @@ void VideoOutDriver::PresentThread(std::stop_token token) {
|
||||
// after three. For that the thread looks at the queue several times per refresh, without
|
||||
// ever waiting for the GPU: the refresh signals themselves stay on time.
|
||||
// SHADPS4_EARLY_FLIP=0 goes back to flipping at refreshes only.
|
||||
static constexpr u32 LooksPerRefresh = 8;
|
||||
const char* precise_setting = std::getenv("SHADPS4_VR_PRECISE_PACING");
|
||||
const bool precise_pacing = vr.IsHeadsetConnected() &&
|
||||
!(precise_setting != nullptr && precise_setting[0] == '0');
|
||||
const u32 LooksPerRefresh = precise_pacing ? 32 : 8;
|
||||
const char* early_setting = std::getenv("SHADPS4_EARLY_FLIP");
|
||||
const bool early_flips =
|
||||
vr.IsHeadsetConnected() && !(early_setting != nullptr && early_setting[0] == '0');
|
||||
HeadsetRefreshClock refresh_clock{vblank_period, LooksPerRefresh};
|
||||
|
||||
Common::AccurateTimer timer{early_flips ? vblank_period / LooksPerRefresh : vblank_period};
|
||||
Common::AccurateTimer timer{early_flips ? vblank_period / LooksPerRefresh : vblank_period,
|
||||
precise_pacing && early_flips};
|
||||
LOG_INFO(Lib_VideoOut, "VR precise presentation pacing {}; {} completion polls per refresh",
|
||||
precise_pacing && early_flips ? "enabled" : "disabled", LooksPerRefresh);
|
||||
|
||||
const auto receive_request = [this] -> Request {
|
||||
std::scoped_lock lk{mutex};
|
||||
@@ -774,6 +781,14 @@ void VideoOutDriver::PresentThread(std::stop_token token) {
|
||||
u32 pace = 2;
|
||||
|
||||
while (!token.stop_requested()) {
|
||||
if (precise_pacing && early_flips) {
|
||||
// The runtime may change from the startup/default rate (or change it live).
|
||||
const float rate = vr.HeadsetRefreshRate();
|
||||
if (rate >= 30.0f && rate <= 400.0f) {
|
||||
timer.SetInterval(std::chrono::nanoseconds{
|
||||
static_cast<s64>(1e9 / (rate * LooksPerRefresh))});
|
||||
}
|
||||
}
|
||||
timer.Start();
|
||||
|
||||
if (DebugState.IsGuestThreadsPaused()) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -65,6 +66,40 @@ static bool HeadsetConnected() {
|
||||
return Core::Vr::Runtime::Instance().IsHeadsetConnected();
|
||||
}
|
||||
|
||||
// Asked to find a kind of device anew (sceVrTrackerRecalibrate), a console's tracker says for
|
||||
// a moment that it is calibrating it, and then that it tracks it again. Titles wait for exactly
|
||||
// that: ASTRO BOT Rescue Mission from its version 1.01 on asks for its controller to be found
|
||||
// anew on the screen that has the player sit inside a silhouette, and stays there until it has
|
||||
// seen the controller's status go from calibrating to anything else.
|
||||
struct Recalibration {
|
||||
/// Process time, in microseconds, up to which the devices count as being calibrated.
|
||||
u64 until{};
|
||||
/// Whether a result has said so yet: the title must get to see it, however late it asks.
|
||||
bool reported{true};
|
||||
};
|
||||
static constexpr u64 RecalibrationTime = 200'000;
|
||||
static std::array<Recalibration, 4> g_recalibrations;
|
||||
static std::mutex g_recalibrations_mutex;
|
||||
|
||||
static void BeginRecalibration(OrbisVrTrackerDeviceType device_type) {
|
||||
std::scoped_lock lock{g_recalibrations_mutex};
|
||||
g_recalibrations[device_type] = {
|
||||
.until = Libraries::Kernel::sceKernelGetProcessTime() + RecalibrationTime,
|
||||
.reported = false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether a result for a device of this kind is to say that it is being calibrated.
|
||||
static bool IsRecalibrating(OrbisVrTrackerDeviceType device_type, u64 now) {
|
||||
std::scoped_lock lock{g_recalibrations_mutex};
|
||||
Recalibration& recalibration = g_recalibrations[device_type];
|
||||
if (recalibration.reported && now >= recalibration.until) {
|
||||
return false;
|
||||
}
|
||||
recalibration.reported = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void WritePose(OrbisVrTrackerPoseData& out, const Core::Vr::Pose& pose) {
|
||||
out.position_x = pose.position.x;
|
||||
out.position_y = pose.position.y;
|
||||
@@ -357,7 +392,12 @@ s32 PS4_SYSV_ABI sceVrTrackerGetResult(const OrbisVrTrackerGetResultParam* param
|
||||
OrbisVrTrackerPlayareaBrightnessRiskType::ORBIS_VR_TRACKER_PLAYAREA_BRIGHTNESS_RISK_LOW;
|
||||
result->led_color = is_pad ? pad_registration->color
|
||||
: OrbisVrTrackerLedColor::ORBIS_VR_TRACKER_LED_COLOR_BLUE;
|
||||
result->status = OrbisVrTrackerStatus::ORBIS_VR_TRACKER_STATUS_TRACKING;
|
||||
result->status =
|
||||
IsRecalibrating(is_hmd ? OrbisVrTrackerDeviceType::ORBIS_VR_TRACKER_DEVICE_HMD
|
||||
: OrbisVrTrackerDeviceType::ORBIS_VR_TRACKER_DEVICE_DUALSHOCK4,
|
||||
now)
|
||||
? OrbisVrTrackerStatus::ORBIS_VR_TRACKER_STATUS_CALIBRATING
|
||||
: OrbisVrTrackerStatus::ORBIS_VR_TRACKER_STATUS_TRACKING;
|
||||
result->position_quality = OrbisVrTrackerQuality::ORBIS_VR_TRACKER_QUALITY_FULL;
|
||||
result->orientation_quality = OrbisVrTrackerQuality::ORBIS_VR_TRACKER_QUALITY_FULL;
|
||||
result->velocity_x = state.linear_velocity.x;
|
||||
@@ -458,7 +498,7 @@ sceVrTrackerNotifyEndOfCpuProcess(const OrbisVrTrackerNotifyEndOfCpuProcessParam
|
||||
}
|
||||
|
||||
s32 PS4_SYSV_ABI sceVrTrackerRecalibrate(const OrbisVrTrackerRecalibrateParam* param) {
|
||||
LOG_ERROR(Lib_VrTracker, "(STUBBED) called");
|
||||
LOG_TRACE(Lib_VrTracker, "called");
|
||||
if (!g_library_initialized) {
|
||||
return ORBIS_VR_TRACKER_ERROR_NOT_INIT;
|
||||
}
|
||||
@@ -501,7 +541,11 @@ s32 PS4_SYSV_ABI sceVrTrackerRecalibrate(const OrbisVrTrackerRecalibrateParam* p
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: handle internal recalibration behaviors.
|
||||
// The host tracks the devices and has nothing to find anew; what the title gets is the
|
||||
// status a console's tracker shows while it does.
|
||||
LOG_INFO(Lib_VrTracker, "called, device_type = {}, calibration_type = {}",
|
||||
static_cast<u32>(device_type), static_cast<u32>(param->calibration_type));
|
||||
BeginRecalibration(device_type);
|
||||
return ORBIS_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -1551,7 +1551,9 @@ MemoryManager::VMAHandle MemoryManager::CarveVMA(VAddr virtual_addr, u64 size) {
|
||||
return vma_handle;
|
||||
}
|
||||
|
||||
ASSERT_MSG(end_in_vma <= vma.size, "Mapping cannot fit inside free region");
|
||||
ASSERT_MSG(end_in_vma <= vma.size,
|
||||
"Mapping of {:#x} bytes at {:#x} cannot fit inside free region {:#x} - {:#x}", size,
|
||||
virtual_addr, vma.base, vma.base + vma.size);
|
||||
|
||||
if (end_in_vma != vma.size) {
|
||||
// Split VMA at the end of the allocated region
|
||||
|
||||
@@ -97,8 +97,34 @@ enum class TransferOption : s32 {
|
||||
Nothing,
|
||||
SdlCancelled = -1,
|
||||
};
|
||||
// Whether the folders an older shadPS4 kept saves and trophies in hold anything.
|
||||
static bool HasOldSavesOrTrophies() {
|
||||
std::error_code ec;
|
||||
const auto user_dir = Common::FS::GetUserPath(Common::FS::PathType::UserDir);
|
||||
const auto old_saves = user_dir / "savedata" / "1";
|
||||
if (fs::exists(old_saves, ec) && !fs::is_empty(old_saves, ec)) {
|
||||
return true;
|
||||
}
|
||||
const auto old_trophies = user_dir / "game_data";
|
||||
if (!fs::is_directory(old_trophies, ec)) {
|
||||
return false;
|
||||
}
|
||||
for (fs::directory_iterator it{old_trophies, ec}, end; !ec && it != end; it.increment(ec)) {
|
||||
if (fs::exists(it->path() / "TrophyFiles", ec)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
TransferOption AskMigrationOption() {
|
||||
TransferOption user_choice = TransferOption::Nothing;
|
||||
// Nothing to move over, nothing to ask. (The question came at every first start, in a
|
||||
// message box that waits behind the game's window, which a player in a headset never
|
||||
// sees: the game then seemed not to start.)
|
||||
if (!HasOldSavesOrTrophies()) {
|
||||
return user_choice;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
SDL_MessageBoxButtonData btns[4]
|
||||
#else
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "core/vr/vr_runtime.h"
|
||||
|
||||
namespace Core::Vr {
|
||||
|
||||
inline std::optional<Fov> CachedHeadsetFov(const nlohmann::json& json,
|
||||
const HeadsetIdentity& identity) {
|
||||
if (identity.runtime.empty() || identity.system.empty() || !json.is_object() ||
|
||||
!json.contains("runtime") || json["runtime"] != identity.runtime ||
|
||||
!json.contains("system") || json["system"] != identity.system ||
|
||||
!json.contains("vendor_id") || json["vendor_id"] != identity.vendor_id ||
|
||||
!json.contains("fov_tan")) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto& tangents = json["fov_tan"];
|
||||
if (!tangents.is_array() || tangents.size() != 4) {
|
||||
return std::nullopt;
|
||||
}
|
||||
for (const auto& tangent : tangents) {
|
||||
if (!tangent.is_number()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const float value = tangent.get<float>();
|
||||
if (!std::isfinite(value) || value < 0.1f || value > 10.0f) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
return Fov{tangents[0].get<float>(), tangents[1].get<float>(), tangents[2].get<float>(),
|
||||
tangents[3].get<float>()};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
#ifdef _WIN32
|
||||
#include "common/windows_sleep_timer.h"
|
||||
#endif
|
||||
|
||||
namespace Core::Vr {
|
||||
// Producer notification wakes immediately; the Windows timeout uses its own high-
|
||||
// resolution timer instead of the coarse timeout of SleepConditionVariableSRW.
|
||||
class LateFrameSignal {
|
||||
public:
|
||||
#ifdef _WIN32
|
||||
LateFrameSignal() : event{CreateEventW(nullptr, FALSE, FALSE, nullptr)} {}
|
||||
~LateFrameSignal() {
|
||||
if (event)
|
||||
CloseHandle(event);
|
||||
}
|
||||
void Notify() {
|
||||
if (event)
|
||||
SetEvent(event);
|
||||
}
|
||||
#else
|
||||
void Notify() {
|
||||
delivered.notify_one();
|
||||
}
|
||||
#endif
|
||||
LateFrameSignal(const LateFrameSignal&) = delete;
|
||||
LateFrameSignal& operator=(const LateFrameSignal&) = delete;
|
||||
#ifndef _WIN32
|
||||
LateFrameSignal() = default;
|
||||
#endif
|
||||
template <typename Ready>
|
||||
bool WaitUntil(std::unique_lock<std::mutex>& lock, std::chrono::steady_clock::time_point until,
|
||||
Ready ready) {
|
||||
#ifdef _WIN32
|
||||
while (!ready()) {
|
||||
const auto remaining = until - std::chrono::steady_clock::now();
|
||||
// Unsupported timers or allocation failure disable waiting, rather than use
|
||||
// a coarse timeout that can consume a whole headset refresh.
|
||||
if (remaining.count() <= 0 || !event || !timer.IsHighResolution())
|
||||
return false;
|
||||
// Producer updates readiness and notifies under the same mutex. Resetting
|
||||
// before unlocking cannot lose a delivery between the predicate and wait.
|
||||
ResetEvent(event);
|
||||
if (!timer.Arm(std::chrono::duration_cast<std::chrono::nanoseconds>(remaining)))
|
||||
return false;
|
||||
const HANDLE handles[]{event, timer.Handle()};
|
||||
lock.unlock();
|
||||
const DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
|
||||
lock.lock();
|
||||
if (ready())
|
||||
return true;
|
||||
if (result != WAIT_OBJECT_0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
return delivered.wait_until(lock, until, ready);
|
||||
#endif
|
||||
}
|
||||
|
||||
private:
|
||||
#ifdef _WIN32
|
||||
HANDLE event{};
|
||||
Common::WindowsSleepTimer timer;
|
||||
#else
|
||||
std::condition_variable delivered;
|
||||
#endif
|
||||
};
|
||||
|
||||
enum class LateFrameResult { Skipped, Ready, TimedOut };
|
||||
|
||||
// Budget begins at the runtime wakeup. The predicate is always checked first so a
|
||||
// delivery between the initial TakeFrame and taking the mutex is not lost.
|
||||
template <typename Ready>
|
||||
LateFrameResult WaitForLateFrame(LateFrameSignal& delivered, std::unique_lock<std::mutex>& lock,
|
||||
std::chrono::steady_clock::time_point woke,
|
||||
std::chrono::steady_clock::time_point last_delivery, float rate,
|
||||
float wait_ms, Ready ready) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
if (ready())
|
||||
return LateFrameResult::Ready;
|
||||
if (!(wait_ms > 0.0f && rate > 30.0f) || last_delivery == Clock::time_point{} ||
|
||||
woke - last_delivery >= std::chrono::duration<double>(2.0 / rate)) {
|
||||
return LateFrameResult::Skipped;
|
||||
}
|
||||
const auto until = woke + std::chrono::duration_cast<Clock::duration>(
|
||||
std::chrono::duration<double, std::milli>(wait_ms));
|
||||
if (Clock::now() >= until)
|
||||
return LateFrameResult::TimedOut;
|
||||
return delivered.WaitUntil(lock, until, ready) ? LateFrameResult::Ready
|
||||
: LateFrameResult::TimedOut;
|
||||
}
|
||||
} // namespace Core::Vr
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
namespace Core::Vr {
|
||||
class MirrorLimiter {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
bool Due(Clock::time_point now, bool local_headset_frame, double fps) {
|
||||
// Desktop-only and exported/remote presentation must never be throttled here.
|
||||
if (!local_headset_frame) {
|
||||
next = {};
|
||||
return true;
|
||||
}
|
||||
if (fps <= 0.0)
|
||||
return false;
|
||||
if (now < next)
|
||||
return false;
|
||||
const auto period =
|
||||
std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>{1.0 / fps});
|
||||
if (next == Clock::time_point{} || now - next >= period)
|
||||
next = now + period;
|
||||
else
|
||||
next += period;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
Clock::time_point next{};
|
||||
};
|
||||
} // namespace Core::Vr
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -12,28 +13,40 @@
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
|
||||
#include <mmdeviceapi.h>
|
||||
#include <objbase.h>
|
||||
#include <propidl.h>
|
||||
#include <unknwn.h>
|
||||
#endif
|
||||
|
||||
#include <SDL3/SDL_events.h>
|
||||
|
||||
#include "common/logging/log.h"
|
||||
#include "core/vr/late_frame_wait.h"
|
||||
#include "common/polyfill_thread.h"
|
||||
#include "common/singleton.h"
|
||||
#include "common/thread.h"
|
||||
#include "core/vr/openxr_host.h"
|
||||
#include "input/controller.h"
|
||||
#include "input/pad_source.h"
|
||||
#include "input/scripted_input.h"
|
||||
#include "input/pad_gestures.h"
|
||||
#include "input/stick_finger.h"
|
||||
#include "input/view_turn.h"
|
||||
#include "video_core/renderer_vulkan/vk_scheduler.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#define XR_USE_PLATFORM_WIN32
|
||||
#endif
|
||||
#define XR_USE_GRAPHICS_API_VULKAN
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
|
||||
#include "core/vr/openxr_view.h"
|
||||
|
||||
namespace Core::Vr {
|
||||
|
||||
namespace {
|
||||
@@ -47,7 +60,7 @@ constexpr u32 NumCommandBuffers = 4;
|
||||
/// Pictures smaller than this, either way, are a title's way of showing nothing.
|
||||
constexpr u32 MinFrameSize = 64;
|
||||
|
||||
/// What runtimes for Windows are known to want of a Vulkan device they share images with. It is
|
||||
/// What runtimes are known to want of a Vulkan device they share images with. It is
|
||||
/// enabled whenever a runtime is there, so that a headset that turns up after the renderer has
|
||||
/// made its device (the player starts the game first and puts the headset on then) can still be
|
||||
/// drawn to; what the runtime itself asks for comes on top once it can be asked.
|
||||
@@ -60,10 +73,15 @@ constexpr const char* UsualInstanceExtensions[] = {
|
||||
constexpr const char* UsualDeviceExtensions[] = {
|
||||
"VK_KHR_dedicated_allocation", "VK_KHR_get_memory_requirements2",
|
||||
"VK_KHR_bind_memory2", "VK_KHR_external_memory",
|
||||
"VK_KHR_external_memory_win32", "VK_KHR_external_semaphore",
|
||||
"VK_KHR_external_semaphore_win32", "VK_KHR_external_fence",
|
||||
"VK_KHR_external_fence_win32", "VK_KHR_timeline_semaphore",
|
||||
"VK_KHR_win32_keyed_mutex", "VK_KHR_image_format_list",
|
||||
"VK_KHR_external_semaphore", "VK_KHR_external_fence",
|
||||
#ifdef _WIN32
|
||||
"VK_KHR_external_memory_win32", "VK_KHR_external_semaphore_win32",
|
||||
"VK_KHR_external_fence_win32", "VK_KHR_win32_keyed_mutex",
|
||||
#else
|
||||
"VK_KHR_external_memory_fd", "VK_KHR_external_semaphore_fd",
|
||||
"VK_KHR_external_fence_fd",
|
||||
#endif
|
||||
"VK_KHR_timeline_semaphore", "VK_KHR_image_format_list",
|
||||
"VK_KHR_maintenance1", "VK_KHR_maintenance2",
|
||||
"VK_KHR_multiview", "VK_KHR_create_renderpass2",
|
||||
};
|
||||
@@ -145,6 +163,7 @@ XrVector3f Rotate(const XrQuaternionf& q, const XrVector3f& v) {
|
||||
return {rotated.x, rotated.y, rotated.z};
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string Narrow(const wchar_t* text) {
|
||||
const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (size <= 1) {
|
||||
@@ -222,6 +241,56 @@ std::string AudioDeviceName(const wchar_t* identifier, EDataFlow flow) {
|
||||
return name;
|
||||
}
|
||||
|
||||
/// The name of the sound device in use whose name ends that way, empty if there is none.
|
||||
std::string ActiveAudioDeviceEnding(std::string_view ending, EDataFlow flow) {
|
||||
static constexpr PROPERTYKEY FriendlyName{
|
||||
{0xa45c254e, 0xdf1c, 0x4efd, {0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0}}, 14};
|
||||
|
||||
if (ending.empty()) {
|
||||
return {};
|
||||
}
|
||||
const HRESULT initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
std::string found;
|
||||
IMMDeviceEnumerator* enumerator = nullptr;
|
||||
if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
|
||||
__uuidof(IMMDeviceEnumerator),
|
||||
reinterpret_cast<void**>(&enumerator)))) {
|
||||
IMMDeviceCollection* devices = nullptr;
|
||||
if (SUCCEEDED(enumerator->EnumAudioEndpoints(flow, DEVICE_STATE_ACTIVE, &devices))) {
|
||||
UINT count = 0;
|
||||
devices->GetCount(&count);
|
||||
for (UINT i = 0; i < count && found.empty(); ++i) {
|
||||
IMMDevice* device = nullptr;
|
||||
if (FAILED(devices->Item(i, &device)) || device == nullptr) {
|
||||
continue;
|
||||
}
|
||||
IPropertyStore* properties = nullptr;
|
||||
if (SUCCEEDED(device->OpenPropertyStore(STGM_READ, &properties))) {
|
||||
PROPVARIANT value;
|
||||
PropVariantInit(&value);
|
||||
if (SUCCEEDED(properties->GetValue(FriendlyName, &value)) &&
|
||||
value.vt == VT_LPWSTR && value.pwszVal != nullptr) {
|
||||
if (std::string name = Narrow(value.pwszVal); name.ends_with(ending)) {
|
||||
found = std::move(name);
|
||||
}
|
||||
}
|
||||
PropVariantClear(&value);
|
||||
properties->Release();
|
||||
}
|
||||
device->Release();
|
||||
}
|
||||
devices->Release();
|
||||
}
|
||||
enumerator->Release();
|
||||
}
|
||||
if (SUCCEEDED(initialized)) {
|
||||
CoUninitialize();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
struct OpenXrHost::Impl {
|
||||
@@ -256,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{};
|
||||
@@ -267,6 +337,10 @@ struct OpenXrHost::Impl {
|
||||
std::vector<std::string> device_extensions;
|
||||
std::string audio_output;
|
||||
std::string audio_input;
|
||||
bool audio_names_known{};
|
||||
// When the runtime is to be asked for the headset's sound devices again (the frame
|
||||
// thread's clock, 0: not at all).
|
||||
std::atomic<s64> audio_ask_at{0};
|
||||
|
||||
// The session, all of it the frame thread's.
|
||||
Graphics graphics{};
|
||||
@@ -296,6 +370,9 @@ struct OpenXrHost::Impl {
|
||||
u32 system_failures{};
|
||||
Clock::time_point last_wanted;
|
||||
bool ever_wanted{};
|
||||
/// Whether a session that is asked for no pictures is made anew after a while (see
|
||||
/// CheckWanted): for the runtime that says nothing when its headset goes away.
|
||||
bool renew_unwanted{};
|
||||
Clock::time_point last_frame_ended;
|
||||
float lose_after{};
|
||||
bool pause_when_away{true};
|
||||
@@ -329,6 +406,7 @@ struct OpenXrHost::Impl {
|
||||
|
||||
// The title's frames on their way here.
|
||||
std::mutex slot_mutex;
|
||||
LateFrameSignal delivered_cv;
|
||||
std::array<Slot, NumSlots> slots;
|
||||
s32 latest{-1};
|
||||
u32 next_slot{};
|
||||
@@ -350,8 +428,12 @@ struct OpenXrHost::Impl {
|
||||
u32 frames_since_optics{};
|
||||
u32 fov_samples{};
|
||||
|
||||
// The headset's own controllers, which stand in for a gamepad where the PC has none.
|
||||
// The headset's own controllers, which stand in for a gamepad where the PC has none, or
|
||||
// has one that is not the one in use (see Input::PadSource).
|
||||
bool use_controllers{true};
|
||||
bool has_frame_controller{};
|
||||
// What of them was in use at the last look (the buttons that play, the sticks).
|
||||
u32 controllers_in_use{};
|
||||
int pad_hand{1};
|
||||
XrVector3f pad_offset{};
|
||||
XrActionSet action_set{XR_NULL_HANDLE};
|
||||
@@ -361,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};
|
||||
@@ -369,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
|
||||
@@ -393,6 +482,10 @@ struct OpenXrHost::Impl {
|
||||
bool sent_touch{};
|
||||
float sent_touch_x{0.5f};
|
||||
float sent_touch_y{0.5f};
|
||||
Input::StickFinger stick_finger;
|
||||
Input::PadGestures gestures;
|
||||
Input::ViewTurn view_turn;
|
||||
bool blowing{};
|
||||
std::atomic<u32> rumble_wanted{};
|
||||
u32 rumble_applied{};
|
||||
Clock::time_point rumble_time;
|
||||
@@ -409,6 +502,8 @@ struct OpenXrHost::Impl {
|
||||
u32 display_frames{};
|
||||
u32 reported_delivered{};
|
||||
u32 copied_frames{};
|
||||
u32 late_frames{};
|
||||
u32 late_wait_timeouts{};
|
||||
u32 head_tracked_frames{};
|
||||
u32 palm_samples{};
|
||||
float palm_distance{};
|
||||
@@ -462,8 +557,15 @@ 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
|
||||
has_audio_guid = enable_if_offered(XR_OCULUS_AUDIO_DEVICE_GUID_EXTENSION_NAME);
|
||||
#endif
|
||||
|
||||
XrInstanceCreateInfo info{XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
std::strcpy(info.applicationInfo.applicationName, "shadPS4");
|
||||
@@ -480,16 +582,26 @@ struct OpenXrHost::Impl {
|
||||
return false;
|
||||
}
|
||||
XrInstanceProperties properties{XR_TYPE_INSTANCE_PROPERTIES};
|
||||
xrGetInstanceProperties(instance, &properties);
|
||||
if (XR_FAILED(xrGetInstanceProperties(instance, &properties))) {
|
||||
LOG_WARNING(Core_Vr, "The OpenXR runtime did not report its identity");
|
||||
DestroyInstance();
|
||||
return false;
|
||||
}
|
||||
runtime_name = fmt::format("{} {}.{}.{}", properties.runtimeName,
|
||||
XR_VERSION_MAJOR(properties.runtimeVersion),
|
||||
XR_VERSION_MINOR(properties.runtimeVersion),
|
||||
XR_VERSION_PATCH(properties.runtimeVersion));
|
||||
// (SHADPS4_XR_RENEW_UNWANTED=1 or 0 says so for any runtime, for tests.)
|
||||
const char* renew = std::getenv("SHADPS4_XR_RENEW_UNWANTED");
|
||||
renew_unwanted = renew != nullptr && renew[0] != '\0'
|
||||
? renew[0] != '0'
|
||||
: runtime_name.starts_with("VirtualDesktopXR");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Asks the runtime for its headset. False while there is none.
|
||||
bool FindSystem() {
|
||||
Runtime::Instance().SetHeadsetIdentity({});
|
||||
// SHADPS4_XR_HIDE_FOR=<seconds>, for tests: the runtime's headset is not there for
|
||||
// that long after the start, as Virtual Desktop's is not until the headset connects.
|
||||
if (hide_for > 0.0f &&
|
||||
@@ -523,7 +635,14 @@ struct OpenXrHost::Impl {
|
||||
if (hand_extension) {
|
||||
properties.next = &hand_properties;
|
||||
}
|
||||
xrGetSystemProperties(instance, system, &properties);
|
||||
if (XR_FAILED(xrGetSystemProperties(instance, system, &properties))) {
|
||||
LOG_WARNING(Core_Vr, "The OpenXR runtime did not report its headset's identity");
|
||||
system = XR_NULL_SYSTEM_ID;
|
||||
return false;
|
||||
}
|
||||
Runtime::Instance().SetHeadsetIdentity(
|
||||
{runtime_name, properties.systemName, properties.vendorId});
|
||||
fov_samples = 0;
|
||||
max_swapchain_width = properties.graphicsProperties.maxSwapchainImageWidth;
|
||||
max_swapchain_height = properties.graphicsProperties.maxSwapchainImageHeight;
|
||||
// (Asked every time: a headset that comes back may track hands where it did not.)
|
||||
@@ -578,37 +697,75 @@ struct OpenXrHost::Impl {
|
||||
/// Where the headset's sound goes and comes from, by the names Windows lists the devices
|
||||
/// under: a runtime that streams to a headset has devices of its own for that.
|
||||
void FindAudioDevices() {
|
||||
if (!has_audio_guid) {
|
||||
return;
|
||||
}
|
||||
using Function = XrResult(XRAPI_PTR*)(XrInstance, wchar_t*);
|
||||
const auto output =
|
||||
GetFunction<Function>(instance, "xrGetAudioOutputDeviceGuidOculus");
|
||||
const auto input = GetFunction<Function>(instance, "xrGetAudioInputDeviceGuidOculus");
|
||||
wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]{};
|
||||
std::string output_name;
|
||||
std::string input_name;
|
||||
if (output != nullptr && XR_SUCCEEDED(output(instance, buffer))) {
|
||||
output_name = AudioDeviceName(buffer, eRender);
|
||||
#ifdef _WIN32
|
||||
if (has_audio_guid) {
|
||||
using Function = XrResult(XRAPI_PTR*)(XrInstance, wchar_t*);
|
||||
const auto output =
|
||||
GetFunction<Function>(instance, "xrGetAudioOutputDeviceGuidOculus");
|
||||
const auto input =
|
||||
GetFunction<Function>(instance, "xrGetAudioInputDeviceGuidOculus");
|
||||
wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]{};
|
||||
if (output != nullptr && XR_SUCCEEDED(output(instance, buffer))) {
|
||||
output_name = AudioDeviceName(buffer, eRender);
|
||||
}
|
||||
std::memset(buffer, 0, sizeof(buffer));
|
||||
if (input != nullptr && XR_SUCCEEDED(input(instance, buffer))) {
|
||||
input_name = AudioDeviceName(buffer, eCapture);
|
||||
}
|
||||
}
|
||||
std::memset(buffer, 0, sizeof(buffer));
|
||||
if (input != nullptr && XR_SUCCEEDED(input(instance, buffer))) {
|
||||
input_name = AudioDeviceName(buffer, eCapture);
|
||||
// Virtual Desktop plays in the headset what is played on a sound device of its own,
|
||||
// which is in the system only while it streams to a headset. While it is there, it is
|
||||
// the headset's, whatever the runtime in use says: Virtual Desktop's own names the
|
||||
// device the system prefers, which need not be that one (a player whose system
|
||||
// preferred the PC's speakers heard the game from those), and SteamVR names none.
|
||||
// Its microphone is always in the system, and only the headset's while it streams.
|
||||
// (SHADPS4_XR_STREAM_AUDIO=<how the names end>, for tests and for other such programs.)
|
||||
static const std::string streamed = [] {
|
||||
const char* ending = std::getenv("SHADPS4_XR_STREAM_AUDIO");
|
||||
return std::string{ending != nullptr ? ending : "(Virtual Desktop Audio)"};
|
||||
}();
|
||||
if (std::string own = ActiveAudioDeviceEnding(streamed, eRender); !own.empty()) {
|
||||
output_name = std::move(own);
|
||||
if (std::string microphone = ActiveAudioDeviceEnding(streamed, eCapture);
|
||||
!microphone.empty()) {
|
||||
input_name = std::move(microphone);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
std::scoped_lock lock{names_mutex};
|
||||
if (audio_names_known && output_name == audio_output && input_name == audio_input) {
|
||||
return;
|
||||
}
|
||||
LOG_INFO(Core_Vr, "The headset's sound: output \"{}\", microphone \"{}\"",
|
||||
output_name.empty() ? "the system's default" : output_name,
|
||||
input_name.empty() ? "the system's default" : input_name);
|
||||
std::scoped_lock lock{names_mutex};
|
||||
audio_names_known = true;
|
||||
audio_output = std::move(output_name);
|
||||
audio_input = std::move(input_name);
|
||||
}
|
||||
|
||||
/// FindAudioDevices again, a moment after sound devices came or went (on the frame
|
||||
/// thread: the instance is its to use).
|
||||
void AskAudioDevicesIfDue() {
|
||||
const s64 due = audio_ask_at.load(std::memory_order_relaxed);
|
||||
if (due == 0 || Clock::now().time_since_epoch().count() < due) {
|
||||
return;
|
||||
}
|
||||
audio_ask_at.store(0, std::memory_order_relaxed);
|
||||
if (instance != XR_NULL_HANDLE) {
|
||||
FindAudioDevices();
|
||||
}
|
||||
}
|
||||
|
||||
// --- the frame thread ---------------------------------------------------------------------
|
||||
|
||||
void Run(std::stop_token stop) {
|
||||
Common::SetCurrentThreadName("shadPS4:XrHost");
|
||||
Common::SetCurrentThreadPriority(Common::ThreadPriority::VeryHigh);
|
||||
while (!stop.stop_requested() && !device_mismatch && !exit_requested) {
|
||||
AskAudioDevicesIfDue();
|
||||
if (!EnsureSession()) {
|
||||
Common::StoppableTimedWait(stop, std::chrono::milliseconds{1500});
|
||||
continue;
|
||||
@@ -620,6 +777,7 @@ struct OpenXrHost::Impl {
|
||||
// and a session made with what answers.
|
||||
session_lost = false;
|
||||
system = XR_NULL_SYSTEM_ID;
|
||||
Runtime::Instance().SetHeadsetIdentity({});
|
||||
if (instance_lost) {
|
||||
DestroyInstance();
|
||||
}
|
||||
@@ -635,6 +793,7 @@ struct OpenXrHost::Impl {
|
||||
}
|
||||
|
||||
void DestroyInstance() {
|
||||
Runtime::Instance().SetHeadsetIdentity({});
|
||||
instance_lost = false;
|
||||
system_failures = 0;
|
||||
if (instance != XR_NULL_HANDLE) {
|
||||
@@ -814,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);
|
||||
@@ -824,48 +987,103 @@ 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;
|
||||
}
|
||||
|
||||
std::vector<XrActionSuggestedBinding> bindings;
|
||||
const auto bind = [&](XrAction action, const char* path) {
|
||||
XrPath binding = XR_NULL_PATH;
|
||||
if (XR_SUCCEEDED(xrStringToPath(instance, path, &binding))) {
|
||||
bindings.push_back({action, binding});
|
||||
for (const int profile : {0, 1, 2}) {
|
||||
const bool index = profile == 1;
|
||||
const bool frame = profile == 2;
|
||||
if (frame && !has_frame_controller) {
|
||||
continue;
|
||||
}
|
||||
std::vector<XrActionSuggestedBinding> bindings;
|
||||
const auto bind = [&](XrAction action, const char* path) {
|
||||
XrPath binding = XR_NULL_PATH;
|
||||
if (XR_SUCCEEDED(xrStringToPath(instance, path, &binding))) {
|
||||
bindings.push_back({action, binding});
|
||||
}
|
||||
};
|
||||
bind(act_move, "/user/hand/left/input/thumbstick");
|
||||
bind(act_finger, "/user/hand/right/input/thumbstick");
|
||||
bind(act_finger_press, "/user/hand/right/input/thumbstick/click");
|
||||
bind(act_cross, "/user/hand/right/input/a/click");
|
||||
// Frame's Touch emulation maps B, X and Y to B; use its native profile instead.
|
||||
bind(act_square, frame ? "/user/hand/right/input/x/click"
|
||||
: "/user/hand/right/input/b/click");
|
||||
bind(act_circle,
|
||||
frame ? "/user/hand/right/input/b/click"
|
||||
: index ? "/user/hand/left/input/a/click" : "/user/hand/left/input/x/click");
|
||||
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");
|
||||
bind(act_r2, "/user/hand/right/input/trigger/value");
|
||||
bind(act_options, frame ? "/user/hand/left/input/view/click"
|
||||
: index ? "/user/hand/left/input/trackpad/force"
|
||||
: "/user/hand/left/input/menu/click");
|
||||
bind(act_l3, "/user/hand/left/input/thumbstick/click");
|
||||
bind(act_pose, pad_hand == 0 ? "/user/hand/left/input/aim/pose"
|
||||
: "/user/hand/right/input/aim/pose");
|
||||
bind(act_grip, "/user/hand/left/input/grip/pose");
|
||||
bind(act_grip, "/user/hand/right/input/grip/pose");
|
||||
bind(act_rumble, "/user/hand/left/output/haptic");
|
||||
bind(act_rumble, "/user/hand/right/output/haptic");
|
||||
XrInteractionProfileSuggestedBinding suggested{
|
||||
XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
|
||||
xrStringToPath(instance,
|
||||
frame ? "/interaction_profiles/valve/frame_controller_valve"
|
||||
: index ? "/interaction_profiles/valve/index_controller"
|
||||
: "/interaction_profiles/oculus/touch_controller",
|
||||
&suggested.interactionProfile);
|
||||
suggested.countSuggestedBindings = static_cast<uint32_t>(bindings.size());
|
||||
suggested.suggestedBindings = bindings.data();
|
||||
if (const XrResult result = xrSuggestInteractionProfileBindings(instance, &suggested);
|
||||
XR_FAILED(result)) {
|
||||
LOG_WARNING(Core_Vr, "The headset's runtime takes no {} controller bindings: {}",
|
||||
frame ? "Frame" : index ? "Index" : "Touch",
|
||||
ResultText(instance, result));
|
||||
} else if (frame) {
|
||||
LOG_INFO(Core_Vr,
|
||||
"Steam Frame native bindings: A = cross, B = circle, X = square, Y = triangle");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
bind(act_move, "/user/hand/left/input/thumbstick");
|
||||
bind(act_finger, "/user/hand/right/input/thumbstick");
|
||||
bind(act_finger_press, "/user/hand/right/input/thumbstick/click");
|
||||
bind(act_cross, "/user/hand/right/input/a/click");
|
||||
bind(act_square, "/user/hand/right/input/b/click");
|
||||
bind(act_circle, "/user/hand/left/input/x/click");
|
||||
bind(act_triangle, "/user/hand/left/input/y/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");
|
||||
bind(act_r2, "/user/hand/right/input/trigger/value");
|
||||
bind(act_options, "/user/hand/left/input/menu/click");
|
||||
bind(act_l3, "/user/hand/left/input/thumbstick/click");
|
||||
bind(act_pose, pad_hand == 0 ? "/user/hand/left/input/aim/pose"
|
||||
: "/user/hand/right/input/aim/pose");
|
||||
bind(act_grip, "/user/hand/left/input/grip/pose");
|
||||
bind(act_grip, "/user/hand/right/input/grip/pose");
|
||||
bind(act_rumble, "/user/hand/left/output/haptic");
|
||||
bind(act_rumble, "/user/hand/right/output/haptic");
|
||||
XrInteractionProfileSuggestedBinding suggested{
|
||||
XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
|
||||
xrStringToPath(instance, "/interaction_profiles/oculus/touch_controller",
|
||||
&suggested.interactionProfile);
|
||||
suggested.countSuggestedBindings = static_cast<uint32_t>(bindings.size());
|
||||
suggested.suggestedBindings = bindings.data();
|
||||
if (const XrResult result = xrSuggestInteractionProfileBindings(instance, &suggested);
|
||||
XR_FAILED(result)) {
|
||||
LOG_WARNING(Core_Vr, "The headset's runtime takes no bindings for its controllers: {}",
|
||||
ResultText(instance, result));
|
||||
return;
|
||||
}
|
||||
|
||||
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
|
||||
@@ -885,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];
|
||||
@@ -905,18 +1131,25 @@ struct OpenXrHost::Impl {
|
||||
sent_buttons = {};
|
||||
sent_axes = {128, 128, 128, 128, 0, 0};
|
||||
sent_touch = false;
|
||||
(*Common::Singleton<Input::GameControllers>::Instance())[0]->ApplyRemoteState(
|
||||
sent_buttons, sent_axes, false, sent_touch_x, sent_touch_y);
|
||||
stick_finger.Reset();
|
||||
gestures.Reset();
|
||||
view_turn.Reset();
|
||||
if (blowing) {
|
||||
blowing = false;
|
||||
Input::SetBlowing(false);
|
||||
}
|
||||
(*Common::Singleton<Input::GameControllers>::Instance())[0]->SetHeadsetPlays(false);
|
||||
Runtime::Instance().ReleasePad();
|
||||
LOG_INFO(Core_Vr, "The headset's controllers no longer stand in for the gamepad: {}",
|
||||
why);
|
||||
}
|
||||
|
||||
/// Reads the headset's controllers and hands what they say on as the first player's
|
||||
/// gamepad, while the PC has no gamepad of its own.
|
||||
/// gamepad, while the PC has no gamepad of its own or they were used after it.
|
||||
void UpdateControllers(XrTime time) {
|
||||
actions_synced = false;
|
||||
if (!actions_ready) {
|
||||
Runtime::Instance().SetGazeTarget({});
|
||||
return;
|
||||
}
|
||||
const XrActiveActionSet active{action_set, XR_NULL_PATH};
|
||||
@@ -925,21 +1158,54 @@ 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;
|
||||
}
|
||||
auto* const controller = (*Common::Singleton<Input::GameControllers>::Instance())[0];
|
||||
if (controller->m_sdl_gamepad != nullptr) {
|
||||
ReleaseControllers("a gamepad is connected to the PC");
|
||||
ApplyRumble(false);
|
||||
return;
|
||||
}
|
||||
const bool gamepad = controller->m_sdl_gamepad != nullptr;
|
||||
|
||||
XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO};
|
||||
bool any_active = false;
|
||||
@@ -974,6 +1240,42 @@ struct OpenXrHost::Impl {
|
||||
return state.currentState;
|
||||
};
|
||||
|
||||
// SHADPS4_XR_TEST_PRESS=<seconds>[,...] and SHADPS4_XR_TEST_TURN=<seconds>[,...], for
|
||||
// tests: so long after the controllers were first read, the right one's A is held for
|
||||
// half a second, or the other hand's grip is held and the right stick flicked to the
|
||||
// right (to the left for a negative time), as if somebody did.
|
||||
bool test_press = false;
|
||||
float test_grip = 0.0f;
|
||||
float test_flick = 0.0f;
|
||||
{
|
||||
static const auto times = [](const char* name) {
|
||||
std::vector<double> list;
|
||||
const char* at = std::getenv(name);
|
||||
while (at != nullptr && *at != '\0') {
|
||||
list.push_back(std::atof(at));
|
||||
at = std::strchr(at, ',');
|
||||
at = at != nullptr ? at + 1 : nullptr;
|
||||
}
|
||||
return list;
|
||||
};
|
||||
static const std::vector<double> presses = times("SHADPS4_XR_TEST_PRESS");
|
||||
static const std::vector<double> turns = times("SHADPS4_XR_TEST_TURN");
|
||||
static const auto start = Clock::now();
|
||||
const double now = std::chrono::duration<double>(Clock::now() - start).count();
|
||||
for (const double at : presses) {
|
||||
test_press = test_press || (now >= at && now < at + 0.5);
|
||||
}
|
||||
for (const double at : turns) {
|
||||
const double from = std::abs(at);
|
||||
if (now >= from && now < from + 0.6) {
|
||||
test_grip = 1.0f;
|
||||
if (now >= from + 0.2 && now < from + 0.4) {
|
||||
test_flick = at < 0.0 ? -1.0f : 1.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using Buttons = Libraries::Pad::OrbisPadButtonDataOffset;
|
||||
Buttons buttons{};
|
||||
const auto add = [&](bool down, Buttons button) {
|
||||
@@ -981,34 +1283,85 @@ struct OpenXrHost::Impl {
|
||||
buttons |= button;
|
||||
}
|
||||
};
|
||||
add(pressed(act_cross), Buttons::Cross);
|
||||
add(pressed(act_cross) || test_press, Buttons::Cross);
|
||||
add(pressed(act_square), Buttons::Square);
|
||||
add(pressed(act_circle), Buttons::Circle);
|
||||
add(pressed(act_triangle), Buttons::Triangle);
|
||||
// Circle and triangle together blow into the microphone (Input::SetBlowing).
|
||||
const bool circle = pressed(act_circle);
|
||||
const bool triangle = pressed(act_triangle);
|
||||
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);
|
||||
add(pulled(act_l1) > 0.5f, Buttons::L1);
|
||||
add(pulled(act_r1) > 0.5f, Buttons::R1);
|
||||
const float left_grip = std::max(pulled(act_l1), pad_hand == 0 ? 0.0f : test_grip);
|
||||
const float right_grip = std::max(pulled(act_r1), pad_hand == 0 ? test_grip : 0.0f);
|
||||
add(left_grip > 0.5f, Buttons::L1);
|
||||
add(right_grip > 0.5f, Buttons::R1);
|
||||
const float left_trigger = pulled(act_l2);
|
||||
const float right_trigger = pulled(act_r2);
|
||||
const XrVector2f move = stick(act_move);
|
||||
const XrVector2f finger = stick(act_finger);
|
||||
XrVector2f finger = stick(act_finger);
|
||||
if (test_flick != 0.0f) {
|
||||
finger.x = test_flick;
|
||||
}
|
||||
if (!any_active) {
|
||||
// Both lie somewhere, asleep.
|
||||
controllers_in_use = 0;
|
||||
ReleaseControllers("they are not in the player's hands");
|
||||
ApplyRumble(false);
|
||||
return;
|
||||
}
|
||||
// With a gamepad on the PC as well, whichever was used last plays. Used: a button
|
||||
// that plays pressed, a stick pushed or pressed in. (Not a trigger, a grip or the
|
||||
// menu button: a hand does that to a controller it only rests on or puts away.)
|
||||
const double seconds =
|
||||
std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
|
||||
const auto pushed = [](const XrVector2f& stick) {
|
||||
return std::max(std::abs(stick.x), std::abs(stick.y)) > 0.5f;
|
||||
};
|
||||
const u32 in_use = (True(buttons & Buttons::Cross) ? 1u : 0u) |
|
||||
(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) |
|
||||
((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)) {
|
||||
ReleaseControllers("the gamepad on the PC was used");
|
||||
ApplyRumble(false);
|
||||
return;
|
||||
}
|
||||
if (blow != blowing) {
|
||||
blowing = blow;
|
||||
Input::SetBlowing(blow);
|
||||
}
|
||||
if (!controllers_used) {
|
||||
controllers_used = true;
|
||||
controller->SetHeadsetPlays(true);
|
||||
LOG_INFO(Core_Vr,
|
||||
"No gamepad is connected to the PC: the headset's controllers stand in for "
|
||||
"it (left stick to move, A = cross, B = square, X = circle, Y = triangle, "
|
||||
"right stick = finger on the touchpad, pressed in = touchpad pressed, left "
|
||||
"menu button = OPTIONS, both sticks pressed in = reset the view; the {} "
|
||||
"one is the controller in the game)",
|
||||
"{}: the headset's controllers stand in for "
|
||||
"it (left stick to move, face buttons follow the controller profile, "
|
||||
"right stick = finger on the touchpad, "
|
||||
"pressed in = touchpad pressed, left menu/trackpad = OPTIONS, "
|
||||
"both sticks pressed in = reset the view; the {} "
|
||||
"one is the controller in the game: its trigger presses the touchpad, its "
|
||||
"grip swipes forward, the other trigger pulls back and lets go; the other "
|
||||
"grip held, the right stick flicked to a side turns the view a step)",
|
||||
gamepad ? "The headset's controllers were used, the gamepad on the PC was "
|
||||
"not (it plays again when it is used)"
|
||||
: "No gamepad is connected to the PC",
|
||||
pad_hand == 0 ? "left" : "right");
|
||||
if (has_frame_controller) {
|
||||
LOG_INFO(Core_Vr,
|
||||
"On Steam Frame, use right A = cross, B = circle, X = square, "
|
||||
"Y = triangle; B + Y = blow");
|
||||
}
|
||||
}
|
||||
|
||||
// Both sticks pressed in is not something a game is played with: it resets the view,
|
||||
@@ -1041,12 +1394,43 @@ struct OpenXrHost::Impl {
|
||||
if (axes[5] > 0) {
|
||||
buttons |= Buttons::R2;
|
||||
}
|
||||
// The finger touches down where the stick points, and in the middle of the touchpad
|
||||
// What the title wants done on the touchpad is on buttons (see Input::PadGestures):
|
||||
// the trigger of the hand that holds the controller in the game presses the pad for
|
||||
// as long as it is pulled, the grip of that hand swipes forward once, the other
|
||||
// hand's trigger pulls back and lets go when it is let go. (They stay L1, R1, L2 and
|
||||
// R2 besides: the title has no use for those in play, but its menus have.)
|
||||
Input::PadGestures::Controls controls;
|
||||
controls.press = (pad_hand == 0 ? left_trigger : right_trigger) > 0.5f;
|
||||
controls.swipe = (pad_hand == 0 ? left_grip : right_grip) > 0.5f;
|
||||
controls.pull = (pad_hand == 0 ? right_trigger : left_trigger) > 0.5f;
|
||||
const Input::PadGestures::Touch made = gestures.Update(seconds, controls);
|
||||
// The grip of the other hand, which is left without a meaning, held: each flick of
|
||||
// the right stick to a side turns the view a step that way (see Input::ViewTurn), for
|
||||
// players who cannot turn round where they sit. The stick moves no finger meanwhile.
|
||||
const bool turning = (pad_hand == 0 ? right_grip : left_grip) > 0.5f;
|
||||
if (const int steps = view_turn.Update(turning, finger.x); steps != 0) {
|
||||
Runtime::Instance().TurnView(steps);
|
||||
}
|
||||
// Besides that, the right stick moves a finger over the touchpad (see
|
||||
// Input::StickFinger: a stick let go lifts the finger where it was, which is what
|
||||
// lets a pull on the stick shoot the game's catapults). A stick pushed away from the
|
||||
// player is the pad's far edge. The finger also touches, in the middle of the pad,
|
||||
// when the stick is only pressed in: a touchpad cannot be pressed without touching it.
|
||||
const bool swiping = std::hypot(finger.x, finger.y) > 0.25f;
|
||||
Input::StickFinger::Touch dragged{false, 0.5f, 0.5f};
|
||||
if (made.down) {
|
||||
stick_finger.Reset();
|
||||
if (made.pressed) {
|
||||
buttons |= Buttons::TouchPad;
|
||||
}
|
||||
} else if (turning) {
|
||||
stick_finger.Reset();
|
||||
} else {
|
||||
dragged = stick_finger.Update(seconds, finger.x, -finger.y);
|
||||
}
|
||||
const bool swiping = made.down || dragged.down;
|
||||
const bool touch = swiping || (right_stick_in && !view_reset);
|
||||
const float touch_x = swiping ? std::clamp(0.5f + finger.x * 0.45f, 0.0f, 1.0f) : 0.5f;
|
||||
const float touch_y = swiping ? std::clamp(0.5f - finger.y * 0.45f, 0.0f, 1.0f) : 0.5f;
|
||||
const float touch_x = made.down ? made.x : dragged.down ? dragged.x : 0.5f;
|
||||
const float touch_y = made.down ? made.y : dragged.down ? dragged.y : 0.5f;
|
||||
if (buttons != sent_buttons || axes != sent_axes || touch != sent_touch ||
|
||||
(touch && (std::abs(touch_x - sent_touch_x) > 0.002f ||
|
||||
std::abs(touch_y - sent_touch_y) > 0.002f))) {
|
||||
@@ -1058,7 +1442,7 @@ struct OpenXrHost::Impl {
|
||||
sent_touch_y = touch_y;
|
||||
}
|
||||
// (A finger that lifts does so from where it was.)
|
||||
controller->ApplyRemoteState(buttons, axes, touch, sent_touch_x, sent_touch_y);
|
||||
controller->ApplyHeadsetState(buttons, axes, touch, sent_touch_x, sent_touch_y);
|
||||
}
|
||||
|
||||
XrSpaceVelocity velocity{XR_TYPE_SPACE_VELOCITY};
|
||||
@@ -1212,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;
|
||||
@@ -1397,6 +1785,10 @@ struct OpenXrHost::Impl {
|
||||
/// the table is not a reason to make sessions all day, and a new session takes the
|
||||
/// headset away from whatever else it shows.
|
||||
/// A session that is on the head and was asked for pictures since is left alone.
|
||||
/// All of this for Virtual Desktop's runtime only. Others say what becomes of their
|
||||
/// sessions through the states and events OpenXR has for it, and ask for no pictures
|
||||
/// whenever nobody looks (SteamVR while its headset rests, or while its dashboard is in
|
||||
/// front): a session ended under them for that is a session they were still using.
|
||||
void CheckWanted(bool wanted, Clock::time_point now) {
|
||||
if (wanted) {
|
||||
last_wanted = now;
|
||||
@@ -1405,7 +1797,7 @@ struct OpenXrHost::Impl {
|
||||
focus_patience = FirstFocusPatience;
|
||||
return;
|
||||
}
|
||||
if (session_lost) {
|
||||
if (session_lost || !renew_unwanted) {
|
||||
return;
|
||||
}
|
||||
if (state == XR_SESSION_STATE_FOCUSED) {
|
||||
@@ -1505,9 +1897,28 @@ struct OpenXrHost::Impl {
|
||||
static_cast<XrTime>(std::clamp(predict_ms, 0.0f, 80.0f) * 1e6f);
|
||||
UpdateHead(pose_time);
|
||||
UpdateControllers(pose_time);
|
||||
AskAudioDevicesIfDue();
|
||||
UpdatePad(pose_time);
|
||||
|
||||
if (const s32 index = TakeFrame(); index >= 0) {
|
||||
s32 taken = TakeFrame();
|
||||
// From elliotttate/AstroQuest: a frame a little late need not become a repeat.
|
||||
// Budget is measured from xrWaitFrame wakeup, not from the start of this wait,
|
||||
// and no Vulkan queue lock is held while the producer is allowed to finish.
|
||||
static const float late_wait_ms =
|
||||
std::clamp(EnvFloat("SHADPS4_XR_LATE_WAIT_MS", 3.0f), 0.0f, 6.0f);
|
||||
if (taken < 0 && late_wait_ms > 0.0f && have_frame &&
|
||||
frame_state.shouldRender == XR_TRUE && refresh_rate > 30.0f) {
|
||||
std::unique_lock lock{slot_mutex};
|
||||
const auto result = WaitForLateFrame(delivered_cv, lock, woke, last_delivery,
|
||||
refresh_rate, late_wait_ms, [this] {
|
||||
return latest >= 0 && slots[latest].state == Slot::State::Ready;
|
||||
});
|
||||
lock.unlock();
|
||||
taken = TakeFrame();
|
||||
if (taken >= 0) ++late_frames;
|
||||
else if (result == LateFrameResult::TimedOut) ++late_wait_timeouts;
|
||||
}
|
||||
if (const s32 index = taken; index >= 0) {
|
||||
const auto started = Clock::now();
|
||||
bool blank = false;
|
||||
const bool copied = CopyFrame(static_cast<u32>(index), blank);
|
||||
@@ -1648,23 +2059,17 @@ struct OpenXrHost::Impl {
|
||||
XrViewState view_state{XR_TYPE_VIEW_STATE};
|
||||
XrView eye_views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
|
||||
uint32_t view_count = 0;
|
||||
if (!XR_SUCCEEDED(xrLocateViews(session, &locate, &view_state, 2, &view_count, eye_views)) ||
|
||||
if (!XR_SUCCEEDED(
|
||||
xrLocateViews(session, &locate, &view_state, 2, &view_count, eye_views)) ||
|
||||
view_count != 2) {
|
||||
return;
|
||||
}
|
||||
// What the headset shows, both eyes together: the left eye's left and the right eye's
|
||||
// right are the outer sides. (Tracked or not: a headset lying on the desk shows as much.)
|
||||
const auto tangent = [](float angle) { return std::tan(std::abs(angle)); };
|
||||
const Fov seen{
|
||||
std::max(tangent(eye_views[0].fov.angleLeft), tangent(eye_views[1].fov.angleRight)),
|
||||
std::max(tangent(eye_views[0].fov.angleRight), tangent(eye_views[1].fov.angleLeft)),
|
||||
std::max(tangent(eye_views[0].fov.angleUp), tangent(eye_views[1].fov.angleUp)),
|
||||
std::max(tangent(eye_views[0].fov.angleDown), tangent(eye_views[1].fov.angleDown)),
|
||||
};
|
||||
if (seen.tan_out > 0.1f && seen.tan_out < 10.0f && seen.tan_in > 0.1f &&
|
||||
seen.tan_in < 10.0f && seen.tan_top > 0.1f && seen.tan_top < 10.0f &&
|
||||
seen.tan_bottom > 0.1f && seen.tan_bottom < 10.0f && ++fov_samples % 600 == 1) {
|
||||
runtime.NoteHeadsetFov(seen);
|
||||
if ((view_state.viewStateFlags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) == 0) {
|
||||
return;
|
||||
}
|
||||
const auto seen = ParallelStereoFov(eye_views[0], eye_views[1]);
|
||||
if (seen && ++fov_samples % 600 == 1) {
|
||||
runtime.NoteHeadsetFov(*seen);
|
||||
}
|
||||
if ((view_state.viewStateFlags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0) {
|
||||
const float dx = eye_views[1].pose.position.x - eye_views[0].pose.position.x;
|
||||
@@ -2226,6 +2631,10 @@ struct OpenXrHost::Impl {
|
||||
"({:.2f} ms at worst)",
|
||||
graphics.queue_index, end_lock_time / frames * 1e3,
|
||||
end_call_time / frames * 1e3, end_call_worst * 1e3);
|
||||
LOG_INFO(Core_Vr, "Late-frame recovery: {} pictures recovered, {} waits timed out",
|
||||
late_frames, late_wait_timeouts);
|
||||
late_frames = 0;
|
||||
late_wait_timeouts = 0;
|
||||
if ((has_hand_tracking || (use_grips && actions_ready)) && !controllers_used) {
|
||||
const float held = pad_samples != 0 ? 1.0f / static_cast<float>(pad_samples) : 0.0f;
|
||||
LOG_INFO(Core_Vr,
|
||||
@@ -2601,6 +3010,7 @@ void OpenXrHost::EndFrame(u32 index, const PresentedFrame& info, vk::Semaphore r
|
||||
}
|
||||
}
|
||||
impl->last_delivery = now;
|
||||
impl->delivered_cv.Notify();
|
||||
}
|
||||
|
||||
void OpenXrHost::DropFrame(u32 index) {
|
||||
@@ -2625,4 +3035,11 @@ std::string OpenXrHost::AudioInputName() const {
|
||||
return impl->audio_input;
|
||||
}
|
||||
|
||||
void OpenXrHost::AudioDevicesChanged() {
|
||||
// (A moment later: the system is still settling when it says so.)
|
||||
impl->audio_ask_at.store(
|
||||
(Clock::now() + std::chrono::milliseconds{700}).time_since_epoch().count(),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
} // namespace Core::Vr
|
||||
@@ -84,6 +84,10 @@ public:
|
||||
/// if the runtime does not say.
|
||||
std::string AudioOutputName() const;
|
||||
std::string AudioInputName() const;
|
||||
/// Sound devices have come or gone: the runtime is asked for the headset's again, shortly.
|
||||
/// (One that streams to a headset has its devices only while it does: asked before, it
|
||||
/// names whatever the system plays on then.)
|
||||
void AudioDevicesChanged();
|
||||
|
||||
private:
|
||||
OpenXrHost();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <openxr/openxr.h>
|
||||
|
||||
#include "core/vr/vr_runtime.h"
|
||||
|
||||
namespace Core::Vr {
|
||||
|
||||
struct ViewTangents {
|
||||
float left;
|
||||
float right;
|
||||
float up;
|
||||
float down;
|
||||
};
|
||||
|
||||
inline std::optional<ViewTangents> ParallelViewTangents(const std::array<Vec3, 4>& corners) {
|
||||
if (std::ranges::any_of(corners, [](const Vec3& ray) {
|
||||
return !std::isfinite(ray.x) || !std::isfinite(ray.y) || !std::isfinite(ray.z) ||
|
||||
ray.z >= -1e-5f;
|
||||
})) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto horizontal =
|
||||
std::minmax({-corners[0].x / corners[0].z, -corners[1].x / corners[1].z,
|
||||
-corners[2].x / corners[2].z, -corners[3].x / corners[3].z});
|
||||
const auto vertical = std::minmax({-corners[0].y / corners[0].z, -corners[1].y / corners[1].z,
|
||||
-corners[2].y / corners[2].z, -corners[3].y / corners[3].z});
|
||||
return ViewTangents{-horizontal.first, horizontal.second, vertical.second, -vertical.first};
|
||||
}
|
||||
|
||||
inline std::optional<ViewTangents> ParallelViewTangents(const XrView& view) {
|
||||
const auto& q = view.pose.orientation;
|
||||
const float length_squared = q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w;
|
||||
if (!std::isfinite(length_squared) || length_squared < 1e-12f) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const Quat orientation = Normalize({q.x, q.y, q.z, q.w});
|
||||
const float left = std::tan(view.fov.angleLeft);
|
||||
const float right = std::tan(view.fov.angleRight);
|
||||
const float up = std::tan(view.fov.angleUp);
|
||||
const float down = std::tan(view.fov.angleDown);
|
||||
return ParallelViewTangents({Rotate(orientation, Vec3{left, up, -1.0f}),
|
||||
Rotate(orientation, Vec3{right, up, -1.0f}),
|
||||
Rotate(orientation, Vec3{left, down, -1.0f}),
|
||||
Rotate(orientation, Vec3{right, down, -1.0f})});
|
||||
}
|
||||
|
||||
inline std::optional<Fov> ParallelStereoFov(const XrView& left, const XrView& right) {
|
||||
const auto left_view = ParallelViewTangents(left);
|
||||
const auto right_view = ParallelViewTangents(right);
|
||||
if (!left_view || !right_view) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const Fov seen{
|
||||
std::max(left_view->left, right_view->right),
|
||||
std::max(left_view->right, right_view->left),
|
||||
std::max(left_view->up, right_view->up),
|
||||
std::max(left_view->down, right_view->down),
|
||||
};
|
||||
for (const float tangent : {seen.tan_out, seen.tan_in, seen.tan_top, seen.tan_bottom}) {
|
||||
if (tangent <= 0.1f || tangent >= 10.0f || !std::isfinite(tangent)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <string_view>
|
||||
|
||||
#include "core/vr/vr_runtime.h"
|
||||
|
||||
namespace Core::Vr {
|
||||
|
||||
enum class DesktopView { Stereo, Spectator, Combined };
|
||||
|
||||
inline DesktopView ParseDesktopView(std::string_view value) {
|
||||
if (value == "combined") {
|
||||
return DesktopView::Combined;
|
||||
}
|
||||
return value == "spectator" ? DesktopView::Spectator : DesktopView::Stereo;
|
||||
}
|
||||
|
||||
inline bool ValidSpectatorFov(const Fov& fov) {
|
||||
for (const float tangent : {fov.tan_out, fov.tan_in, fov.tan_top, fov.tan_bottom}) {
|
||||
if (!std::isfinite(tangent) || tangent < 0.1f || tangent > 100.0f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline float DesktopViewAspect(DesktopView view, const Fov& fov, float eye_aspect) {
|
||||
if (view == DesktopView::Stereo) {
|
||||
return eye_aspect * 2.0f;
|
||||
}
|
||||
if (view == DesktopView::Spectator || !ValidSpectatorFov(fov)) {
|
||||
return eye_aspect;
|
||||
}
|
||||
return eye_aspect * 2.0f * std::max(fov.tan_out, fov.tan_in) / (fov.tan_out + fov.tan_in);
|
||||
}
|
||||
|
||||
struct SpectatorRect {
|
||||
s32 x;
|
||||
s32 y;
|
||||
u32 width;
|
||||
u32 height;
|
||||
};
|
||||
|
||||
inline SpectatorRect SpectatorContentRect(u32 width, u32 height, float aspect, bool crop) {
|
||||
if (width == 0 || height == 0 || !std::isfinite(aspect) || aspect <= 0.0f) {
|
||||
return {0, 0, width, height};
|
||||
}
|
||||
const bool fill_width = crop || aspect > static_cast<float>(width) / height;
|
||||
const u32 content_width = fill_width ? width : std::max(1u, static_cast<u32>(height * aspect));
|
||||
const u32 content_height =
|
||||
fill_width ? std::max(1u, static_cast<u32>(width / aspect)) : height;
|
||||
return {(static_cast<s32>(width) - static_cast<s32>(content_width)) / 2,
|
||||
(static_cast<s32>(height) - static_cast<s32>(content_height)) / 2,
|
||||
content_width, content_height};
|
||||
}
|
||||
|
||||
struct HorizontalEyeRegion {
|
||||
u32 x;
|
||||
u32 width;
|
||||
u32 clip_x;
|
||||
u32 clip_width;
|
||||
};
|
||||
|
||||
inline std::array<HorizontalEyeRegion, 2> CombinedEyeRegions(u32 width, const Fov& fov) {
|
||||
if (!ValidSpectatorFov(fov)) {
|
||||
return {{{0, width, 0, width}, {0, width, 0, 0}}};
|
||||
}
|
||||
const float outer = std::max(fov.tan_out, fov.tan_in);
|
||||
const auto start = static_cast<u32>(std::lround(width * (outer - fov.tan_out) / (2 * outer)));
|
||||
const auto end = static_cast<u32>(std::lround(width * (outer + fov.tan_in) / (2 * outer)));
|
||||
const auto secondary = fov.tan_out >= fov.tan_in
|
||||
? HorizontalEyeRegion{width - end, end - start, end, width - end}
|
||||
: HorizontalEyeRegion{width - end, end - start, 0, start};
|
||||
return {{{start, end - start, start, end - start}, secondary}};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -216,6 +216,12 @@ void HostLink::ReadLoop() {
|
||||
if ((pad.flags & Protocol::PadPose::RecenterYaw) != 0) {
|
||||
runtime.ResetPadYaw();
|
||||
}
|
||||
if ((pad.flags & Protocol::PadPose::TurnLeft) != 0) {
|
||||
runtime.TurnView(-1);
|
||||
}
|
||||
if ((pad.flags & Protocol::PadPose::TurnRight) != 0) {
|
||||
runtime.TurnView(1);
|
||||
}
|
||||
const Vec3 position{pad.position[0], pad.position[1], pad.position[2]};
|
||||
if ((pad.flags & Protocol::PadPose::AssumedOffset) != 0) {
|
||||
runtime.SetPadOffset(position);
|
||||
@@ -223,13 +229,16 @@ void HostLink::ReadLoop() {
|
||||
}
|
||||
const Vec3 velocity{pad.linear_velocity[0], pad.linear_velocity[1],
|
||||
pad.linear_velocity[2]};
|
||||
if ((pad.flags & Protocol::PadPose::OrientationValid) != 0 &&
|
||||
const Quat orientation = Normalize(
|
||||
{pad.orientation[0], pad.orientation[1], pad.orientation[2], pad.orientation[3]});
|
||||
const bool held = (pad.flags & Protocol::PadPose::HeldInHands) != 0;
|
||||
if (!held && (pad.flags & Protocol::PadPose::OrientationValid) != 0 &&
|
||||
(pad.flags & Protocol::PadPose::PositionValid) != 0) {
|
||||
// A device fixed to the controller tells everything there is to know.
|
||||
// A device fixed to the controller, or one that is the controller, tells
|
||||
// everything there is to know.
|
||||
DeviceState state;
|
||||
state.pose.position = position;
|
||||
state.pose.orientation = Normalize({pad.orientation[0], pad.orientation[1],
|
||||
pad.orientation[2], pad.orientation[3]});
|
||||
state.pose.orientation = orientation;
|
||||
state.linear_velocity = velocity;
|
||||
state.angular_velocity = {pad.angular_velocity[0], pad.angular_velocity[1],
|
||||
pad.angular_velocity[2]};
|
||||
@@ -237,6 +246,14 @@ void HostLink::ReadLoop() {
|
||||
runtime.UpdatePad(state);
|
||||
break;
|
||||
}
|
||||
// Nothing tells everything (any more): a controller that was a tracked device a
|
||||
// moment ago is placed by what else is known of it from here on. Without this it
|
||||
// would stay where that device was last seen for as long as no motion sensor of
|
||||
// its own speaks up, which for a controller that has none is for good.
|
||||
runtime.ReleasePad();
|
||||
if (held && (pad.flags & Protocol::PadPose::OrientationValid) != 0) {
|
||||
runtime.UpdatePadHeldOrientation(orientation);
|
||||
}
|
||||
if ((pad.flags & Protocol::PadPose::PositionValid) != 0) {
|
||||
runtime.UpdatePadPosition(position, velocity);
|
||||
} else {
|
||||
|
||||
@@ -112,6 +112,14 @@ struct PadPose {
|
||||
/// The player asks for the view to be reset: where the head is now is where they sit, and
|
||||
/// the way it faces is straight ahead. (The message needs nothing else to be valid.)
|
||||
static constexpr std::uint32_t RecenterSeat = 1u << 5;
|
||||
/// `orientation` is not the controller's own but what the hands holding it give away of
|
||||
/// how it is turned and tilted: it stands in for motion sensors the controller does not
|
||||
/// have, and `position` is taken as it is without `OrientationValid`.
|
||||
static constexpr std::uint32_t HeldInHands = 1u << 6;
|
||||
/// The player asks to be turned round one step where they sit, to the left or to the
|
||||
/// right (see Runtime::TurnView). (The message needs nothing else to be valid.)
|
||||
static constexpr std::uint32_t TurnLeft = 1u << 7;
|
||||
static constexpr std::uint32_t TurnRight = 1u << 8;
|
||||
|
||||
Header header{.type = MessageType::PadPose};
|
||||
std::uint32_t flags{};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
@@ -13,6 +14,7 @@
|
||||
#include "common/logging/log.h"
|
||||
#include "common/path_util.h"
|
||||
#include "core/libraries/system/systemservice.h"
|
||||
#include "core/vr/headset_fov_cache.h"
|
||||
#include "core/vr/vr_host_link.h"
|
||||
#include "core/vr/vr_runtime.h"
|
||||
#ifdef ENABLE_OPENXR_HOST
|
||||
@@ -34,29 +36,6 @@ Quat Conjugate(const Quat& q) {
|
||||
return {-q.x, -q.y, -q.z, q.w};
|
||||
}
|
||||
|
||||
Quat Normalize(const Quat& q) {
|
||||
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 {};
|
||||
}
|
||||
return {q.x / length, q.y / length, q.z / length, q.w / length};
|
||||
}
|
||||
|
||||
Vec3 Rotate(const Quat& q, const Vec3& v) {
|
||||
// v' = v + 2 * cross(q.xyz, cross(q.xyz, v) + q.w * v)
|
||||
const Vec3 u{q.x, q.y, q.z};
|
||||
const Vec3 t{
|
||||
u.y * v.z - u.z * v.y + q.w * v.x,
|
||||
u.z * v.x - u.x * v.z + q.w * v.y,
|
||||
u.x * v.y - u.y * v.x + q.w * v.z,
|
||||
};
|
||||
return {
|
||||
v.x + 2.0f * (u.y * t.z - u.z * t.y),
|
||||
v.y + 2.0f * (u.z * t.x - u.x * t.z),
|
||||
v.z + 2.0f * (u.x * t.y - u.y * t.x),
|
||||
};
|
||||
}
|
||||
|
||||
Quat FromYawPitch(float yaw, float pitch) {
|
||||
const Quat qy{0.0f, std::sin(yaw * 0.5f), 0.0f, std::cos(yaw * 0.5f)};
|
||||
const Quat qp{std::sin(pitch * 0.5f), 0.0f, 0.0f, std::cos(pitch * 0.5f)};
|
||||
@@ -112,6 +91,16 @@ bool EnvFlag(const char* name, bool& out) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::filesystem::path OwnPadPlacePath() {
|
||||
return Common::FS::GetUserPath(Common::FS::PathType::UserDir) / "vr_controller.json";
|
||||
}
|
||||
|
||||
/// How far from the standard place the player's own may be: within reach, and before them.
|
||||
Vec3 WithinReach(const Vec3& offset) {
|
||||
return {std::clamp(offset.x, -0.40f, 0.40f), std::clamp(offset.y, -0.70f, 0.30f),
|
||||
std::clamp(offset.z, -0.90f, -0.15f)};
|
||||
}
|
||||
|
||||
FileConfig LoadFileConfig() {
|
||||
FileConfig result;
|
||||
const auto path = Common::FS::GetUserPath(Common::FS::PathType::UserDir) / "vr.json";
|
||||
@@ -176,6 +165,9 @@ FileConfig LoadFileConfig() {
|
||||
if (const char* of = std::getenv("SHADPS4_VR_FOV_OF"); of != nullptr) {
|
||||
result.config.fov_from_headset = std::string_view{of} == "headset";
|
||||
}
|
||||
if (const char* symmetric = std::getenv("SHADPS4_VR_FOV_SYMMETRIC"); symmetric != nullptr) {
|
||||
result.config.fov_symmetric = std::string_view{symmetric} == "1";
|
||||
}
|
||||
if (const char* fov = std::getenv("SHADPS4_VR_FOV"); fov != nullptr && *fov != '\0') {
|
||||
result.config.fov_scale =
|
||||
std::clamp(static_cast<float>(std::atof(fov)) / 100.0f, 0.5f, 1.2f);
|
||||
@@ -200,6 +192,33 @@ Runtime& Runtime::Instance() {
|
||||
void Runtime::Configure(bool psvr_supported, bool psvr_required) {
|
||||
const FileConfig file_config = LoadFileConfig();
|
||||
config = file_config.config;
|
||||
if (std::ifstream file{OwnPadPlacePath()}; file) {
|
||||
const auto json = nlohmann::json::parse(file, nullptr, false);
|
||||
if (const auto it = json.is_object() ? json.find("own_place") : json.end();
|
||||
it != json.end() && it->is_array() && it->size() == 3 && (*it)[0].is_number() &&
|
||||
(*it)[1].is_number() && (*it)[2].is_number()) {
|
||||
own_pad_offset = WithinReach(
|
||||
{(*it)[0].get<float>(), (*it)[1].get<float>(), (*it)[2].get<float>()});
|
||||
}
|
||||
}
|
||||
// An external host can measure its headset before starting this process. Seed the same
|
||||
// optics path the PC OpenXR host uses, so the title's first FOV query cannot race IPC or
|
||||
// accidentally fall back to a PlayStation VR's projection on the first launch.
|
||||
if (const char* optics = std::getenv("SHADPS4_VR_HEADSET_FOV_TAN"); optics != nullptr) {
|
||||
Fov fov{};
|
||||
char trailing{};
|
||||
const int count = std::sscanf(optics, "%f,%f,%f,%f %c", &fov.tan_out, &fov.tan_in,
|
||||
&fov.tan_top, &fov.tan_bottom, &trailing);
|
||||
const auto usable = [](float value) {
|
||||
return std::isfinite(value) && value > 0.1f && value < 10.0f;
|
||||
};
|
||||
if (count == 4 && usable(fov.tan_out) && usable(fov.tan_in) && usable(fov.tan_top) &&
|
||||
usable(fov.tan_bottom)) {
|
||||
NoteHeadsetFov(fov);
|
||||
} else {
|
||||
LOG_WARNING(Core_Vr, "Ignoring invalid SHADPS4_VR_HEADSET_FOV_TAN");
|
||||
}
|
||||
}
|
||||
switch (file_config.mode) {
|
||||
case HeadsetMode::On:
|
||||
config.headset_connected = true;
|
||||
@@ -270,12 +289,18 @@ void Runtime::RecenterSeatLocked() {
|
||||
pad_attitude = Normalize(Multiply(turned, pad_attitude));
|
||||
pad.pose.orientation = pad_attitude;
|
||||
}
|
||||
if (view_turn != 0.0f && !pad_attitude_valid && !pad_position_tracked) {
|
||||
// A controller that nothing knows anything of was turned with the player (TurnView):
|
||||
// it points straight ahead again, as they face.
|
||||
pad.pose.orientation = {};
|
||||
}
|
||||
previous_seat_position = seat_position;
|
||||
previous_seat_yaw = seat_yaw;
|
||||
seat_changed = std::chrono::steady_clock::now();
|
||||
seat_position = host_head.pose.position;
|
||||
seat_yaw = yaw;
|
||||
seat_valid = true;
|
||||
view_turn = 0.0f;
|
||||
PlaceHead();
|
||||
// Where the controller was seen is of the old seat; the host says where it is every frame.
|
||||
pad_seen = false;
|
||||
@@ -303,6 +328,63 @@ void Runtime::FixSeat() {
|
||||
title_asked = true;
|
||||
}
|
||||
|
||||
void Runtime::TurnView(int steps) {
|
||||
static const float step = [] {
|
||||
float degrees = 30.0f;
|
||||
if (const char* value = std::getenv("SHADPS4_VR_TURN"); value != nullptr && *value != '\0') {
|
||||
degrees = std::clamp(static_cast<float>(std::atof(value)), 0.0f, 90.0f);
|
||||
}
|
||||
return degrees / 57.29578f;
|
||||
}();
|
||||
if (steps == 0 || step == 0.0f) {
|
||||
return;
|
||||
}
|
||||
std::scoped_lock lock{mutex};
|
||||
if (!seat_valid || !host_head.tracked) {
|
||||
return;
|
||||
}
|
||||
// (Angles about +Y count to the left.)
|
||||
const float left = -step * static_cast<float>(steps);
|
||||
// What the title sees of the player turns by this; the seat, in the headset's own
|
||||
// space, by as much the other way.
|
||||
const Quat turn = FromAxisAngle({0.0f, 1.0f, 0.0f}, left);
|
||||
const Quat seat_turn = Conjugate(turn);
|
||||
previous_seat_position = seat_position;
|
||||
previous_seat_yaw = seat_yaw;
|
||||
seat_changed = std::chrono::steady_clock::now();
|
||||
// The seat turns about the head: the head stays where it is in the title's world.
|
||||
const Vec3& at = host_head.pose.position;
|
||||
const Vec3 from_seat = Rotate(
|
||||
seat_turn, {at.x - seat_position.x, at.y - seat_position.y, at.z - seat_position.z});
|
||||
seat_position = {at.x - from_seat.x, at.y - from_seat.y, at.z - from_seat.z};
|
||||
seat_yaw = Normalize(Multiply(seat_turn, seat_yaw));
|
||||
view_turn += left;
|
||||
PlaceHead();
|
||||
// The controller is in the player's hands and turns with them. One that a host locates is
|
||||
// placed anew with every pose; one that is not keeps its place before the player (see
|
||||
// GetPad) and, by its own sensors, the way it points from there.
|
||||
if (pad_attitude_valid) {
|
||||
pad_attitude = Normalize(Multiply(turn, pad_attitude));
|
||||
}
|
||||
if (!pad_position_tracked) {
|
||||
// (Also one that nothing has ever said anything of: it points straight ahead of the
|
||||
// player, whichever way that is.)
|
||||
pad.pose.orientation =
|
||||
pad_attitude_valid ? pad_attitude : Normalize(Multiply(turn, pad.pose.orientation));
|
||||
++pad.sequence;
|
||||
}
|
||||
pad_seen_offset = Rotate(turn, pad_seen_offset);
|
||||
pad_seen = false;
|
||||
pad_shown_valid = false;
|
||||
pad_yaw_reference_valid = false;
|
||||
LOG_INFO(Core_Vr,
|
||||
"The view turns a step to the {}: the head stays at {:.2f} {:.2f} {:.2f} of the "
|
||||
"title's space and faces {:.0f} degrees to the left of where it faced when the "
|
||||
"view was last reset",
|
||||
steps > 0 ? "right" : "left", head.pose.position.x, head.pose.position.y,
|
||||
head.pose.position.z, view_turn * 57.29578f);
|
||||
}
|
||||
|
||||
void Runtime::RequestRecenter() {
|
||||
RecenterSeat();
|
||||
// What the console sends a title when the player asks for the view to be reset: the
|
||||
@@ -312,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
|
||||
@@ -358,6 +455,7 @@ void Runtime::UpdatePad(const DeviceState& host_state) {
|
||||
pad.sequence = sequence;
|
||||
pad.tracked = true;
|
||||
pad_position_tracked = true;
|
||||
pad_host_pose_time = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
void Runtime::ReleasePad() {
|
||||
@@ -371,7 +469,8 @@ void Runtime::ReleasePad() {
|
||||
pad_shown_position = pad.pose.position;
|
||||
pad_shown_valid = true;
|
||||
pad_shown_time = std::chrono::steady_clock::now();
|
||||
pad.pose.orientation = pad_attitude_valid ? pad_attitude : Quat{};
|
||||
pad.pose.orientation =
|
||||
pad_attitude_valid ? pad_attitude : FromAxisAngle({0.0f, 1.0f, 0.0f}, view_turn);
|
||||
pad.linear_velocity = {};
|
||||
pad.angular_velocity = {};
|
||||
++pad.sequence;
|
||||
@@ -386,6 +485,15 @@ void Runtime::UpdatePadOrientation(const Quat& orientation, const Vec3& angular_
|
||||
pad_position_tracked = false;
|
||||
}
|
||||
|
||||
void Runtime::UpdatePadHeldOrientation(const Quat& host_orientation) {
|
||||
std::scoped_lock lock{mutex};
|
||||
pad.pose.orientation = Normalize(Multiply(Conjugate(seat_yaw), host_orientation));
|
||||
pad.angular_velocity = {};
|
||||
++pad.sequence;
|
||||
pad.tracked = true;
|
||||
pad_position_tracked = false;
|
||||
}
|
||||
|
||||
void Runtime::UpdatePadAcceleration(const Vec3& acceleration) {
|
||||
std::scoped_lock lock{mutex};
|
||||
pad_acceleration = acceleration;
|
||||
@@ -464,6 +572,12 @@ void Runtime::UpdatePadGyro(const Vec3& angular_velocity) {
|
||||
pad_attitude = Normalize(pad_attitude);
|
||||
}
|
||||
|
||||
// A host that says where the controller is and how it is turned (the headset's own
|
||||
// controller, standing in for it) is believed over the sensors of a gamepad that is
|
||||
// there as well, on a desk: those only keep count of how that one is turned.
|
||||
if (pad_position_tracked && now - pad_host_pose_time < std::chrono::milliseconds{200}) {
|
||||
return;
|
||||
}
|
||||
pad.pose.orientation = pad_attitude;
|
||||
pad.angular_velocity = Rotate(pad_attitude, angular_velocity);
|
||||
++pad.sequence;
|
||||
@@ -566,6 +680,38 @@ void Runtime::SetPadOffset(const Vec3& offset) {
|
||||
pad_seen_offset_valid = false;
|
||||
}
|
||||
|
||||
void Runtime::MoveOwnPadPlace(const Vec3& by) {
|
||||
Vec3 place;
|
||||
{
|
||||
std::scoped_lock lock{mutex};
|
||||
own_pad_offset =
|
||||
WithinReach({own_pad_offset.x + by.x, own_pad_offset.y + by.y, own_pad_offset.z + by.z});
|
||||
own_pad_place = true;
|
||||
place = own_pad_offset;
|
||||
}
|
||||
LOG_INFO(Core_Vr,
|
||||
"The controller, while nothing sees where it is, is held to be at the player's own "
|
||||
"place: {:.2f} m to the right of the eyes, {:.2f} below and {:.2f} ahead",
|
||||
place.x, -place.y, -place.z);
|
||||
std::ofstream file{OwnPadPlacePath()};
|
||||
file << nlohmann::json{{"own_place", {place.x, place.y, place.z}}}.dump() << "\n";
|
||||
}
|
||||
|
||||
void Runtime::SwitchPadPlace() {
|
||||
bool own;
|
||||
Vec3 place;
|
||||
{
|
||||
std::scoped_lock lock{mutex};
|
||||
own_pad_place = !own_pad_place;
|
||||
own = own_pad_place;
|
||||
place = own ? own_pad_offset : config.pad_offset;
|
||||
}
|
||||
LOG_INFO(Core_Vr,
|
||||
"The controller, while nothing sees where it is, is held to be at {} place: {:.2f} "
|
||||
"m to the right of the eyes, {:.2f} below and {:.2f} ahead",
|
||||
own ? "the player's own" : "the standard", place.x, -place.y, -place.z);
|
||||
}
|
||||
|
||||
void Runtime::UpdatePadYawReference(float yaw) {
|
||||
std::scoped_lock lock{mutex};
|
||||
// The host counts the heading from its own straight ahead.
|
||||
@@ -630,6 +776,14 @@ void Runtime::UpdateOptics(const Fov& fov, float ipd) {
|
||||
config.ipd = ipd;
|
||||
}
|
||||
|
||||
void Runtime::SetHeadsetIdentity(const HeadsetIdentity& identity) {
|
||||
std::scoped_lock lock{mutex};
|
||||
if (headset_identity != identity) {
|
||||
headset_identity = identity;
|
||||
has_headset_fov = false;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::filesystem::path HeadsetFovPath() {
|
||||
@@ -644,6 +798,7 @@ float Degrees(float tangent) {
|
||||
|
||||
void Runtime::NoteHeadsetFov(const Fov& fov) {
|
||||
bool changed;
|
||||
HeadsetIdentity identity;
|
||||
{
|
||||
std::scoped_lock lock{mutex};
|
||||
const auto differs = [](float a, float b) { return std::abs(Degrees(a) - Degrees(b)) > 0.5f; };
|
||||
@@ -653,6 +808,7 @@ void Runtime::NoteHeadsetFov(const Fov& fov) {
|
||||
differs(fov.tan_bottom, headset_fov.tan_bottom);
|
||||
headset_fov = fov;
|
||||
has_headset_fov = true;
|
||||
identity = headset_identity;
|
||||
}
|
||||
headset_fov_known.notify_all();
|
||||
if (!changed) {
|
||||
@@ -664,9 +820,14 @@ void Runtime::NoteHeadsetFov(const Fov& fov) {
|
||||
Degrees(fov.tan_out), Degrees(fov.tan_in), Degrees(fov.tan_top),
|
||||
Degrees(fov.tan_bottom), Degrees(fov.tan_out) + Degrees(fov.tan_in),
|
||||
Degrees(fov.tan_top) + Degrees(fov.tan_bottom));
|
||||
// For the next start, when the title asks before the headset has said.
|
||||
if (identity.runtime.empty() || identity.system.empty()) {
|
||||
return;
|
||||
}
|
||||
std::ofstream file{HeadsetFovPath()};
|
||||
file << nlohmann::json{{"fov_tan", {fov.tan_out, fov.tan_in, fov.tan_top, fov.tan_bottom}}}
|
||||
file << nlohmann::json{{"runtime", identity.runtime},
|
||||
{"system", identity.system},
|
||||
{"vendor_id", identity.vendor_id},
|
||||
{"fov_tan", {fov.tan_out, fov.tan_in, fov.tan_top, fov.tan_bottom}}}
|
||||
.dump()
|
||||
<< "\n";
|
||||
}
|
||||
@@ -695,30 +856,28 @@ Fov Runtime::TitleFov() {
|
||||
}
|
||||
}
|
||||
if (from == nullptr) {
|
||||
// As the headset showed it the last time.
|
||||
std::ifstream file{HeadsetFovPath()};
|
||||
const auto json = file ? nlohmann::json::parse(file, nullptr, false) : nlohmann::json{};
|
||||
const auto usable = [](const nlohmann::json& tangents) {
|
||||
if (!tangents.is_array() || tangents.size() != 4) {
|
||||
return false;
|
||||
}
|
||||
for (const auto& tangent : tangents) {
|
||||
if (!tangent.is_number() || tangent.get<float>() < 0.1f ||
|
||||
tangent.get<float>() > 10.0f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (json.is_object() && json.contains("fov_tan") && usable(json["fov_tan"])) {
|
||||
base = {json["fov_tan"][0].get<float>(), json["fov_tan"][1].get<float>(),
|
||||
json["fov_tan"][2].get<float>(), json["fov_tan"][3].get<float>()};
|
||||
std::scoped_lock lock{mutex};
|
||||
if (has_headset_fov) {
|
||||
base = headset_fov;
|
||||
from = "the headset's own";
|
||||
} else if (const auto cached = CachedHeadsetFov(json, headset_identity)) {
|
||||
base = *cached;
|
||||
from = "the headset's, as it was the last time (it has not said yet)";
|
||||
} else {
|
||||
from = "a PlayStation VR's (the headset has not said what it shows)";
|
||||
}
|
||||
}
|
||||
const float scale = config.fov_scale;
|
||||
if (config.fov_symmetric) {
|
||||
// Scaling asymmetric per-eye bounds also shrinks the binocular overlap: directions
|
||||
// visible on one eye's temple side become black on the other's nose side. Render an
|
||||
// encompassing, centred frustum in both eyes instead. At 100% it covers the entire
|
||||
// real headset; the compositor clips the extra area to the actual lens bounds.
|
||||
base.tan_out = base.tan_in = std::max(base.tan_out, base.tan_in);
|
||||
base.tan_top = base.tan_bottom = std::max(base.tan_top, base.tan_bottom);
|
||||
}
|
||||
const Fov fov{base.tan_out * scale, base.tan_in * scale, base.tan_top * scale,
|
||||
base.tan_bottom * scale};
|
||||
{
|
||||
@@ -726,9 +885,10 @@ Fov Runtime::TitleFov() {
|
||||
config.fov = fov;
|
||||
}
|
||||
LOG_INFO(Core_Vr,
|
||||
"The title draws {:.0f}% of {}: {:.1f}/{:.1f}/{:.1f}/{:.1f} degrees an eye (out, in, "
|
||||
"The title draws {:.0f}% of {}{}: {:.1f}/{:.1f}/{:.1f}/{:.1f} degrees an eye (out, in, "
|
||||
"up, down)",
|
||||
scale * 100.0f, from, Degrees(fov.tan_out), Degrees(fov.tan_in), Degrees(fov.tan_top),
|
||||
scale * 100.0f, config.fov_symmetric ? "the symmetric render envelope of " : "", from,
|
||||
Degrees(fov.tan_out), Degrees(fov.tan_in), Degrees(fov.tan_top),
|
||||
Degrees(fov.tan_bottom));
|
||||
return fov;
|
||||
}
|
||||
@@ -837,7 +997,15 @@ DeviceState Runtime::GetPad() {
|
||||
pad_seen_position.z - pad_anchor.z};
|
||||
pad_seen_offset_valid = true;
|
||||
}
|
||||
const Vec3& assumed = pad_seen_offset_valid ? pad_seen_offset : config.pad_offset;
|
||||
// (The player's own place is theirs to say: it counts for more than where the controller
|
||||
// was last seen.)
|
||||
// (Its own place and the standard one are before the player whichever way TurnView has
|
||||
// them face; where it was seen is where it was seen.)
|
||||
const Vec3 assumed =
|
||||
!own_pad_place && pad_seen_offset_valid
|
||||
? pad_seen_offset
|
||||
: Rotate(FromAxisAngle({0.0f, 1.0f, 0.0f}, view_turn),
|
||||
own_pad_place ? own_pad_offset : config.pad_offset);
|
||||
const Vec3 goal = seen ? pad_seen_position
|
||||
: Vec3{pad_anchor.x + assumed.x, pad_anchor.y + assumed.y,
|
||||
pad_anchor.z + assumed.z};
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "common/types.h"
|
||||
|
||||
@@ -33,8 +35,28 @@ struct Pose {
|
||||
|
||||
Quat Multiply(const Quat& a, const Quat& b);
|
||||
Quat Conjugate(const Quat& q);
|
||||
Quat Normalize(const Quat& q);
|
||||
Vec3 Rotate(const Quat& q, const Vec3& v);
|
||||
inline Quat Normalize(const Quat& q) {
|
||||
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 {};
|
||||
}
|
||||
return {q.x / length, q.y / length, q.z / length, q.w / length};
|
||||
}
|
||||
|
||||
inline Vec3 Rotate(const Quat& q, const Vec3& v) {
|
||||
const Vec3 u{q.x, q.y, q.z};
|
||||
const Vec3 t{
|
||||
u.y * v.z - u.z * v.y + q.w * v.x,
|
||||
u.z * v.x - u.x * v.z + q.w * v.y,
|
||||
u.x * v.y - u.y * v.x + q.w * v.z,
|
||||
};
|
||||
return {
|
||||
v.x + 2.0f * (u.y * t.z - u.z * t.y),
|
||||
v.y + 2.0f * (u.z * t.x - u.x * t.z),
|
||||
v.z + 2.0f * (u.x * t.y - u.y * t.x),
|
||||
};
|
||||
}
|
||||
|
||||
Quat FromYawPitch(float yaw, float pitch);
|
||||
/// Yaw about +Y, then pitch about +X, then roll about +Z, in radians.
|
||||
Quat FromYawPitchRoll(float yaw, float pitch, float roll);
|
||||
@@ -50,6 +72,21 @@ 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;
|
||||
u32 vendor_id{};
|
||||
|
||||
bool operator==(const HeadsetIdentity&) const = default;
|
||||
};
|
||||
|
||||
/// A tracked device sample, expressed in PSVR tracker space: metres, +X right, +Y up and
|
||||
/// -Z towards the camera the player is facing.
|
||||
struct DeviceState {
|
||||
@@ -87,6 +124,9 @@ struct Config {
|
||||
/// The field of view the title is told of is the headset's own (as the host found it), not
|
||||
/// a PlayStation VR's; fov_scale applies to either.
|
||||
bool fov_from_headset{false};
|
||||
/// Use matching render bounds for both eyes, enclosing the detected headset frusta.
|
||||
/// This preserves binocular coverage when a standalone host narrows the view.
|
||||
bool fov_symmetric{false};
|
||||
float fov_scale{1.0f};
|
||||
/// Where the player's resting head position sits in tracker space. The PS Camera is the
|
||||
/// origin, so the player is placed a comfortable distance in front of it.
|
||||
@@ -133,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
|
||||
@@ -143,6 +185,12 @@ public:
|
||||
/// RecenterSeat, and the title is told to take stock again, the way the console tells it
|
||||
/// when the player asks for the view to be reset.
|
||||
void RequestRecenter();
|
||||
/// Turns the player round where they sit, by so many steps to the right (to the left if
|
||||
/// negative): for those who cannot turn round themselves. The head stays where it is in
|
||||
/// the title's world and faces another way; a controller that nothing locates comes
|
||||
/// along. The title is told nothing: to it the player has turned. A reset of the view
|
||||
/// faces them straight ahead again. (SHADPS4_VR_TURN=<degrees> is the step: 30, 0 for none.)
|
||||
void TurnView(int steps);
|
||||
/// For a host whose poses are counted from the seat already (scripted tests): the seat is
|
||||
/// the origin of its space and stays there until RecenterSeat is called.
|
||||
void FixSeat();
|
||||
@@ -153,6 +201,9 @@ public:
|
||||
/// For hosts that only know how the controller is turned (from its motion sensors) and not
|
||||
/// where it is. The runtime then places it with Config::pad_offset.
|
||||
void UpdatePadOrientation(const Quat& orientation, const Vec3& angular_velocity);
|
||||
/// The same for a host that works out how the controller is turned from what it sees of
|
||||
/// it (the hands holding a controller without motion sensors), in its own space.
|
||||
void UpdatePadHeldOrientation(const Quat& host_orientation);
|
||||
/// The controller's own motion sensors, for hosts that have nothing better. Readings are in
|
||||
/// the controller's frame (+X right, +Y out of the face buttons, +Z towards the player), in
|
||||
/// rad/s and m/s² with gravity included, as SDL reports them. The runtime works out how the
|
||||
@@ -185,6 +236,16 @@ public:
|
||||
void ClearPadPosition();
|
||||
/// Replaces Config::pad_offset, and forgets where the controller was last seen.
|
||||
void SetPadOffset(const Vec3& offset);
|
||||
/// A controller that nothing locates is held to be at a fixed place before the player:
|
||||
/// the standard one (Config::pad_offset, which is where the title looks for it when it
|
||||
/// starts) or one of the player's own choosing, for wherever the standard one is in the
|
||||
/// way of the view or out of reach of what the controller is to be held to. The player's
|
||||
/// own place is kept in the user folder; every start begins at the standard one.
|
||||
/// Moves the player's own place by so many metres (right, up, towards the player), and
|
||||
/// puts the controller there.
|
||||
void MoveOwnPadPlace(const Vec3& by);
|
||||
/// From the standard place to the player's own, or back.
|
||||
void SwitchPadPlace();
|
||||
/// What the host can tell about the controller's heading (0 = straight ahead, positive to
|
||||
/// the left). The attitude worked out from the motion sensors is pulled towards it, which
|
||||
/// takes out the drift a gyroscope has about the vertical.
|
||||
@@ -195,6 +256,7 @@ public:
|
||||
void SetPadLight(u8 red, u8 green, u8 blue);
|
||||
void SetPadFeedbackListener(std::function<void(const PadFeedback&)> listener);
|
||||
void UpdateOptics(const Fov& fov, float ipd);
|
||||
void SetHeadsetIdentity(const HeadsetIdentity& identity);
|
||||
/// What the host's headset shows of the world, both eyes together (the widest of the two to
|
||||
/// every side), as soon as it knows. Kept in the user folder for later starts.
|
||||
void NoteHeadsetFov(const Fov& fov);
|
||||
@@ -247,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.
|
||||
@@ -261,13 +325,20 @@ private:
|
||||
// are only shown after.
|
||||
Vec3 previous_seat_position;
|
||||
Quat previous_seat_yaw;
|
||||
// How far TurnView has turned the player since the view was last reset, to the left.
|
||||
float view_turn{};
|
||||
std::chrono::steady_clock::time_point seat_changed;
|
||||
bool title_asked{};
|
||||
DeviceState pad;
|
||||
bool pad_position_tracked{};
|
||||
// When a host last said where the controller is and how it is turned (UpdatePad).
|
||||
std::chrono::steady_clock::time_point pad_host_pose_time;
|
||||
// Smoothed point the untracked controller hangs off.
|
||||
Vec3 pad_anchor;
|
||||
bool pad_anchor_valid{};
|
||||
// The player's own place for it, from that point, and whether it is there.
|
||||
Vec3 own_pad_offset{0.0f, -0.30f, -0.45f};
|
||||
bool own_pad_place{};
|
||||
std::chrono::steady_clock::time_point pad_anchor_time;
|
||||
// Attitude estimated from the controller's motion sensors.
|
||||
Quat pad_attitude;
|
||||
@@ -293,6 +364,7 @@ private:
|
||||
DisplayRefresh display_refresh;
|
||||
// The host headset's field of view, once known.
|
||||
std::condition_variable headset_fov_known;
|
||||
HeadsetIdentity headset_identity;
|
||||
Fov headset_fov;
|
||||
bool has_headset_fov{};
|
||||
PadFeedback pad_feedback;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <fmt/xchar.h>
|
||||
#include <hwinfo/hwinfo.h>
|
||||
|
||||
#include "common/console_language.h"
|
||||
#include "common/debug.h"
|
||||
#include "common/logging/log.h"
|
||||
#include "common/thread.h"
|
||||
@@ -283,6 +284,21 @@ void Emulator::Run(std::filesystem::path file, std::vector<std::string> args,
|
||||
// Switch to configured log
|
||||
Common::Log::Switch((!id.empty() && EmulatorSettings.IsLogSeparate()) ? id + ".log"
|
||||
: "shad_log.txt");
|
||||
// SHADPS4_CONSOLE_LANGUAGE: the language the console is set to, which is the one a title
|
||||
// speaks if it has it. As a language tag ("fr-FR", as Windows and Android name theirs) or
|
||||
// as the console's own number. Whatever starts the emulator for a player says theirs here.
|
||||
if (const char* wanted = std::getenv("SHADPS4_CONSOLE_LANGUAGE");
|
||||
wanted != nullptr && wanted[0] != '\0') {
|
||||
if (const auto language = Common::ConsoleLanguageFromTag(wanted)) {
|
||||
EmulatorSettings.SetConsoleLanguage(*language);
|
||||
LOG_INFO(Core, "The console's language: {} (asked for as \"{}\")",
|
||||
Common::ConsoleLanguageName(*language), wanted);
|
||||
} else {
|
||||
LOG_INFO(Core,
|
||||
"The console's language: {} (it has none for \"{}\", which was asked for)",
|
||||
Common::ConsoleLanguageName(EmulatorSettings.GetConsoleLanguage()), wanted);
|
||||
}
|
||||
}
|
||||
|
||||
auto guest_eboot_path = "/app0/" + eboot_name.generic_string();
|
||||
const auto eboot_path = mnt->GetHostPath(guest_eboot_path);
|
||||
@@ -368,9 +384,18 @@ void Emulator::Run(std::filesystem::path file, std::vector<std::string> args,
|
||||
LOG_INFO(Loader, "PSVR Supported: {}", (bool)psf_attributes.support_ps_vr.Value());
|
||||
LOG_INFO(Loader, "PSVR Required: {}", (bool)psf_attributes.require_ps_vr.Value());
|
||||
}
|
||||
Core::KnownTitle::Prepare();
|
||||
#ifndef ENABLE_BACHATA_RUNTIME
|
||||
// The console's address space is set aside before anything of a headset is looked for.
|
||||
// A title maps its memory at addresses of its own choosing (ASTRO BOT Rescue Mission its
|
||||
// heap at 0x300000000), and what an OpenXR runtime brings into the process when it is
|
||||
// loaded may come to lie right there if it is first: SteamVR's did, and the title stopped
|
||||
// at its start with "Mapping cannot fit inside free region". (After Prepare, which says
|
||||
// how much memory the title is to have.)
|
||||
memory = Core::Memory::Instance();
|
||||
#endif
|
||||
Core::Vr::Runtime::Instance().Configure(psf_attributes.support_ps_vr.Value() != 0,
|
||||
psf_attributes.require_ps_vr.Value() != 0);
|
||||
Core::KnownTitle::Prepare();
|
||||
if (!args.empty()) {
|
||||
const auto argc = std::min<size_t>(args.size(), 32);
|
||||
for (auto i = 0; i < argc; i++) {
|
||||
@@ -394,9 +419,6 @@ void Emulator::Run(std::filesystem::path file, std::vector<std::string> args,
|
||||
Common::Singleton<FileSys::HandleTable>::Instance()->CreateStdHandles();
|
||||
|
||||
// Initialize components
|
||||
#ifndef ENABLE_BACHATA_RUNTIME
|
||||
memory = Core::Memory::Instance();
|
||||
#endif
|
||||
controllers = Common::Singleton<Input::GameControllers>::Instance();
|
||||
#ifndef ENABLE_BACHATA_RUNTIME
|
||||
linker = Common::Singleton<Core::Linker>::Instance();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <sstream>
|
||||
@@ -142,6 +143,9 @@ static void NoteViewButtons(OrbisPadButtonDataOffset before, OrbisPadButtonDataO
|
||||
}
|
||||
|
||||
void GameController::Button(OrbisPadButtonDataOffset button, bool is_pressed) {
|
||||
if (HeadsetPlays()) {
|
||||
return;
|
||||
}
|
||||
const auto before = m_state.buttonsState;
|
||||
m_state.OnButton(button, is_pressed);
|
||||
PushState();
|
||||
@@ -151,32 +155,86 @@ void GameController::Button(OrbisPadButtonDataOffset button, bool is_pressed) {
|
||||
}
|
||||
|
||||
void GameController::Axis(Input::Axis axis, int value, bool smooth) {
|
||||
if (HeadsetPlays()) {
|
||||
return;
|
||||
}
|
||||
m_state.OnAxis(axis, value, smooth);
|
||||
PushState();
|
||||
}
|
||||
|
||||
OrbisPadButtonDataOffset GameController::ApplyRemoteLocked(OrbisPadButtonDataOffset buttons,
|
||||
const std::array<int, 6>& axes,
|
||||
bool touch_down, float touch_x,
|
||||
float touch_y) {
|
||||
const OrbisPadButtonDataOffset before = m_state.buttonsState;
|
||||
m_connected = true;
|
||||
m_connected_count = 1;
|
||||
m_state.buttonsState = buttons;
|
||||
for (int i = 0; i < std::to_underlying(Axis::AxisMax); ++i) {
|
||||
m_state.OnAxis(static_cast<Input::Axis>(i), axes[i], false);
|
||||
}
|
||||
m_state.OnTouchpad(0, touch_down, touch_x, touch_y);
|
||||
m_state.time = Libraries::Kernel::sceKernelGetProcessTime();
|
||||
m_states_queue.Push(m_state);
|
||||
return before;
|
||||
}
|
||||
|
||||
void GameController::ApplyRemoteState(OrbisPadButtonDataOffset buttons,
|
||||
const std::array<int, 6>& axes, bool touch_down,
|
||||
float touch_x, float touch_y) {
|
||||
OrbisPadButtonDataOffset before;
|
||||
{
|
||||
std::lock_guard lock(m_states_queue_mutex);
|
||||
before = m_state.buttonsState;
|
||||
m_connected = true;
|
||||
m_connected_count = 1;
|
||||
m_state.buttonsState = buttons;
|
||||
for (int i = 0; i < std::to_underlying(Axis::AxisMax); ++i) {
|
||||
m_state.OnAxis(static_cast<Input::Axis>(i), axes[i], false);
|
||||
}
|
||||
m_state.OnTouchpad(0, touch_down, touch_x, touch_y);
|
||||
m_state.time = Libraries::Kernel::sceKernelGetProcessTime();
|
||||
m_states_queue.Push(m_state);
|
||||
before = ApplyRemoteLocked(buttons, axes, touch_down, touch_x, touch_y);
|
||||
}
|
||||
if (IsFirstController(this)) {
|
||||
NoteViewButtons(before, buttons);
|
||||
}
|
||||
}
|
||||
|
||||
void GameController::ApplyHeadsetState(OrbisPadButtonDataOffset buttons,
|
||||
const std::array<int, 6>& axes, bool touch_down,
|
||||
float touch_x, float touch_y) {
|
||||
OrbisPadButtonDataOffset before;
|
||||
{
|
||||
std::lock_guard lock(m_states_queue_mutex);
|
||||
// (The gamepad may have taken the controller back a moment ago, from another thread.)
|
||||
if (!HeadsetPlays()) {
|
||||
return;
|
||||
}
|
||||
before = ApplyRemoteLocked(buttons, axes, touch_down, touch_x, touch_y);
|
||||
}
|
||||
if (IsFirstController(this)) {
|
||||
NoteViewButtons(before, buttons);
|
||||
}
|
||||
}
|
||||
|
||||
void GameController::SetHeadsetPlays(bool plays) {
|
||||
OrbisPadButtonDataOffset before;
|
||||
{
|
||||
std::lock_guard lock(m_states_queue_mutex);
|
||||
if (HeadsetPlays() == plays) {
|
||||
return;
|
||||
}
|
||||
m_headset_plays.store(plays, std::memory_order_relaxed);
|
||||
// Whoever played until now leaves nothing held.
|
||||
static constexpr std::array<int, 6> Rest{128, 128, 128, 128, 0, 0};
|
||||
before = ApplyRemoteLocked({}, Rest, false, 0.5f, 0.5f);
|
||||
}
|
||||
{
|
||||
std::scoped_lock lock{m_finger_mutex};
|
||||
m_finger_down = false;
|
||||
m_stick_touch = false;
|
||||
m_gesture_press = false;
|
||||
m_stick_finger.Reset();
|
||||
m_gestures.Reset();
|
||||
m_view_turn.Reset();
|
||||
}
|
||||
if (IsFirstController(this)) {
|
||||
NoteViewButtons(before, {});
|
||||
}
|
||||
}
|
||||
|
||||
void GameController::Gyro(int id) {
|
||||
m_state.OnGyro(gyro_buf);
|
||||
PushState();
|
||||
@@ -189,35 +247,86 @@ void GameController::Gyro(int id) {
|
||||
|
||||
void GameController::UpdateStickTouch() {
|
||||
// A controller without a touchpad of its own (or whose touchpad nobody touches): the right
|
||||
// stick moves a finger that touches down at the pad's centre. Titles made for a headset
|
||||
// have their players swipe, and have no use for a second stick.
|
||||
// stick moves a finger over it (see StickFinger for how). Titles made for a headset have
|
||||
// their players swipe and drag, and have no use for a second stick.
|
||||
// SHADPS4_STICK_TOUCHPAD=0 leaves the stick a stick.
|
||||
static const bool enabled = [] {
|
||||
const char* value = std::getenv("SHADPS4_STICK_TOUCHPAD");
|
||||
return value == nullptr || value[0] != '0';
|
||||
}();
|
||||
if (!enabled || m_sdl_gamepad == nullptr ||
|
||||
!Core::Vr::Runtime::Instance().IsHeadsetConnected()) {
|
||||
if (m_sdl_gamepad == nullptr || !Core::Vr::Runtime::Instance().IsHeadsetConnected() ||
|
||||
HeadsetPlays()) {
|
||||
return;
|
||||
}
|
||||
const float x =
|
||||
static_cast<float>(SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_RIGHTX)) / 32767.0f;
|
||||
const float y =
|
||||
static_cast<float>(SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_RIGHTY)) / 32767.0f;
|
||||
const bool wanted = !m_finger_down && std::hypot(x, y) > 0.25f;
|
||||
if (wanted) {
|
||||
const float touch_x = std::clamp(0.5f + x * 0.45f, 0.0f, 1.0f);
|
||||
const float touch_y = std::clamp(0.5f + y * 0.45f, 0.0f, 1.0f);
|
||||
if (!m_stick_touch || std::abs(touch_x - m_stick_touch_x) > 0.002f ||
|
||||
std::abs(touch_y - m_stick_touch_y) > 0.002f) {
|
||||
m_stick_touch_x = touch_x;
|
||||
m_stick_touch_y = touch_y;
|
||||
ApplyTouch(0, true, touch_x, touch_y);
|
||||
std::scoped_lock lock{m_finger_mutex};
|
||||
// The left shoulder button, which such a title has no use for in play, held: each flick
|
||||
// of the right stick to a side turns the view a step that way (see ViewTurn), for players
|
||||
// who cannot turn round where they sit. The stick moves no finger meanwhile. (Not while
|
||||
// the PS button is held: it gives the shoulder buttons another meaning.)
|
||||
const bool turning =
|
||||
SDL_GetGamepadButton(m_sdl_gamepad, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) &&
|
||||
!SDL_GetGamepadButton(m_sdl_gamepad, SDL_GAMEPAD_BUTTON_GUIDE);
|
||||
if (const int steps = m_view_turn.Update(
|
||||
turning, static_cast<float>(SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_RIGHTX)) /
|
||||
32767.0f);
|
||||
steps != 0) {
|
||||
Core::Vr::Runtime::Instance().TurnView(steps);
|
||||
}
|
||||
if (m_finger_down) {
|
||||
// A real finger is on the pad: the stick's has nothing to say, and is not back on
|
||||
// the pad the moment the real one lifts either.
|
||||
m_stick_finger.Reset();
|
||||
return;
|
||||
}
|
||||
const double now =
|
||||
std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
// A gamepad that has no touchpad at all also has buttons for what such a title wants
|
||||
// done on one (see PadGestures): the right trigger presses the pad for as long as it is
|
||||
// pulled, the right shoulder button swipes forward once, the left trigger pulls back and
|
||||
// lets go when it is let go. The title has no use for those buttons in play, and is
|
||||
// still told of them. (Not while the PS button is held: it gives them other meanings.)
|
||||
PadGestures::Touch made{};
|
||||
if (SDL_GetNumGamepadTouchpads(m_sdl_gamepad) == 0) {
|
||||
PadGestures::Controls controls;
|
||||
if (!SDL_GetGamepadButton(m_sdl_gamepad, SDL_GAMEPAD_BUTTON_GUIDE)) {
|
||||
static constexpr s16 Pulled = 16384;
|
||||
controls.press =
|
||||
SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > Pulled;
|
||||
controls.swipe =
|
||||
SDL_GetGamepadButton(m_sdl_gamepad, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER);
|
||||
controls.pull =
|
||||
SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > Pulled;
|
||||
}
|
||||
} else if (m_stick_touch && !m_finger_down) {
|
||||
made = m_gestures.Update(now, controls);
|
||||
if (made.pressed != m_gesture_press) {
|
||||
m_gesture_press = made.pressed;
|
||||
Button(OrbisPadButtonDataOffset::TouchPad, made.pressed);
|
||||
}
|
||||
}
|
||||
StickFinger::Touch touch{false, m_stick_touch_x, m_stick_touch_y};
|
||||
if (made.down) {
|
||||
m_stick_finger.Reset();
|
||||
touch = {true, made.x, made.y};
|
||||
} else if (turning) {
|
||||
m_stick_finger.Reset();
|
||||
} else if (enabled) {
|
||||
const float x = static_cast<float>(SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_RIGHTX)) /
|
||||
32767.0f;
|
||||
const float y = static_cast<float>(SDL_GetGamepadAxis(m_sdl_gamepad, SDL_GAMEPAD_AXIS_RIGHTY)) /
|
||||
32767.0f;
|
||||
touch = m_stick_finger.Update(now, x, y);
|
||||
}
|
||||
if (touch.down) {
|
||||
if (!m_stick_touch || std::abs(touch.x - m_stick_touch_x) > 0.002f ||
|
||||
std::abs(touch.y - m_stick_touch_y) > 0.002f) {
|
||||
m_stick_touch_x = touch.x;
|
||||
m_stick_touch_y = touch.y;
|
||||
ApplyTouch(0, true, touch.x, touch.y);
|
||||
}
|
||||
} else if (m_stick_touch) {
|
||||
ApplyTouch(0, false, m_stick_touch_x, m_stick_touch_y);
|
||||
}
|
||||
m_stick_touch = wanted;
|
||||
m_stick_touch = touch.down;
|
||||
}
|
||||
|
||||
void GameController::Acceleration(int id) {
|
||||
@@ -286,7 +395,8 @@ bool GameController::SetVibration(u8 smallMotor, u8 largeMotor) {
|
||||
if (IsFirstController(this)) {
|
||||
Core::Vr::Runtime::Instance().SetPadVibration(smallMotor, largeMotor);
|
||||
}
|
||||
if (m_sdl_gamepad != nullptr) {
|
||||
// (A gamepad that lies on the desk while the headset's controllers play stays still.)
|
||||
if (m_sdl_gamepad != nullptr && !HeadsetPlays()) {
|
||||
return SDL_RumbleGamepad(m_sdl_gamepad, (smallMotor / 255.0f) * 0xFFFF,
|
||||
(largeMotor / 255.0f) * 0xFFFF, -1);
|
||||
}
|
||||
@@ -294,10 +404,23 @@ bool GameController::SetVibration(u8 smallMotor, u8 largeMotor) {
|
||||
}
|
||||
|
||||
void GameController::SetTouchpadState(int touchIndex, bool touchDown, float x, float y) {
|
||||
// A finger on the real touchpad: the right stick stops standing in for one.
|
||||
if (HeadsetPlays()) {
|
||||
return;
|
||||
}
|
||||
// A finger on the real touchpad: the right stick stops standing in for one. (Its finger
|
||||
// lifts first: the real one coming down is a touch of its own, not that one moving.)
|
||||
std::scoped_lock lock{m_finger_mutex};
|
||||
if (touchIndex == 0) {
|
||||
if (m_stick_touch) {
|
||||
ApplyTouch(0, false, m_stick_touch_x, m_stick_touch_y);
|
||||
m_stick_touch = false;
|
||||
}
|
||||
if (touchDown && !m_finger_down && !m_touchpad_noted) {
|
||||
m_touchpad_noted = true;
|
||||
LOG_INFO(Input, "The controller's touchpad feels a finger");
|
||||
}
|
||||
m_finger_down = touchDown;
|
||||
m_stick_touch = false;
|
||||
m_stick_finger.Reset();
|
||||
}
|
||||
ApplyTouch(touchIndex, touchDown, x, y);
|
||||
}
|
||||
@@ -432,6 +555,15 @@ void GameControllers::TryOpenSDLControllers() {
|
||||
static std::unordered_set<SDL_JoystickID> left_unused;
|
||||
for (int j = 0; j < controller_count; j++) {
|
||||
SDL_JoystickID id = new_joysticks[j];
|
||||
#ifdef ENABLE_OPENXR_HOST
|
||||
// Steam's virtual gamepad bypasses SDL's device filter and duplicates OpenXR input.
|
||||
if (Core::Vr::Runtime::Instance().IsHeadsetConnected() &&
|
||||
SDL_GetGamepadVendorForID(id) == 0x28de && SDL_GetGamepadProductForID(id) == 0x11e0) {
|
||||
LOG_INFO(Input,
|
||||
"Steam Frame controllers use OpenXR; their duplicate SDL gamepad is not used");
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
if (assigned_ids.contains(id))
|
||||
continue;
|
||||
if (one_player && (slot_taken[0] || controllers[0]->m_sdl_gamepad != nullptr)) {
|
||||
@@ -449,6 +581,14 @@ void GameControllers::TryOpenSDLControllers() {
|
||||
|
||||
SDL_Gamepad* pad = SDL_OpenGamepad(id);
|
||||
if (!pad) {
|
||||
static std::unordered_set<SDL_JoystickID> refused;
|
||||
if (refused.insert(id).second) {
|
||||
const char* name = SDL_GetGamepadNameForID(id);
|
||||
LOG_WARNING(Input,
|
||||
"Controller {} ({:04x}:{:04x}) cannot be opened and is not used: {}",
|
||||
name != nullptr ? name : "unnamed", SDL_GetGamepadVendorForID(id),
|
||||
SDL_GetGamepadProductForID(id), SDL_GetError());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -476,8 +616,11 @@ void GameControllers::TryOpenSDLControllers() {
|
||||
i + 1, name != nullptr ? name : "unnamed", SDL_GetGamepadVendor(pad),
|
||||
SDL_GetGamepadProduct(pad), path != nullptr ? path : "?",
|
||||
SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO) ? "yes" : "no",
|
||||
SDL_GetNumGamepadTouchpads(pad) > 0 ? "yes" : "no, the right stick "
|
||||
"stands in",
|
||||
SDL_GetNumGamepadTouchpads(pad) > 0
|
||||
? "yes"
|
||||
: "no: the right stick stands in for a finger, the right "
|
||||
"trigger presses the pad, the right shoulder button swipes "
|
||||
"forward, the left trigger pulls back and lets go",
|
||||
SDL_GetBooleanProperty(SDL_GetGamepadProperties(pad),
|
||||
SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false)
|
||||
? "yes"
|
||||
@@ -507,11 +650,16 @@ void GameControllers::TryOpenSDLControllers() {
|
||||
}
|
||||
if (is_first_check) [[unlikely]] {
|
||||
is_first_check = false;
|
||||
if (controller_count - move_count == 0) {
|
||||
auto u = UserManagement.GetUserByPlayerIndex(1);
|
||||
controllers[0]->user_id = u->user_id;
|
||||
controllers[0]->ConnectController(nullptr);
|
||||
UserManagement.LoginUser(u, 1);
|
||||
// The first player is logged in from the start, gamepad or not. (Counting the gamepads
|
||||
// there are is not enough: one that is listed and cannot be opened, as a virtual one
|
||||
// that is just going away, left nobody logged in, and a title that takes its first
|
||||
// player for granted stopped right at its start.)
|
||||
if (controllers[0]->user_id == Libraries::UserService::ORBIS_USER_SERVICE_USER_ID_INVALID) {
|
||||
if (auto u = UserManagement.GetUserByPlayerIndex(1); u != nullptr) {
|
||||
controllers[0]->user_id = u->user_id;
|
||||
controllers[0]->ConnectController(nullptr);
|
||||
UserManagement.LoginUser(u, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
SDL_free(new_joysticks);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -13,6 +14,9 @@
|
||||
#include "common/ring_buffer_queue.h"
|
||||
#include "core/libraries/pad/pad.h"
|
||||
#include "core/libraries/system/userservice.h"
|
||||
#include "input/pad_gestures.h"
|
||||
#include "input/stick_finger.h"
|
||||
#include "input/view_turn.h"
|
||||
|
||||
struct SDL_Gamepad;
|
||||
|
||||
@@ -100,6 +104,17 @@ public:
|
||||
void ApplyRemoteState(Libraries::Pad::OrbisPadButtonDataOffset buttons,
|
||||
const std::array<int, 6>& axes, bool touch_down, float touch_x,
|
||||
float touch_y);
|
||||
/// In a headset, the headset's own controllers can play this controller in place of the
|
||||
/// PC's gamepad (see Input::PadSource). While they do, what the gamepad's buttons, sticks
|
||||
/// and touchpad say is left aside. The controller is at rest after either change.
|
||||
void SetHeadsetPlays(bool plays);
|
||||
bool HeadsetPlays() const {
|
||||
return m_headset_plays.load(std::memory_order_relaxed);
|
||||
}
|
||||
/// ApplyRemoteState for the headset's controllers: nothing unless they play.
|
||||
void ApplyHeadsetState(Libraries::Pad::OrbisPadButtonDataOffset buttons,
|
||||
const std::array<int, 6>& axes, bool touch_down, float touch_x,
|
||||
float touch_y);
|
||||
void Gyro(int id);
|
||||
void Acceleration(int id);
|
||||
void UpdateGyro(const float gyro[3]);
|
||||
@@ -135,14 +150,29 @@ public:
|
||||
|
||||
private:
|
||||
void PushState();
|
||||
/// With m_states_queue_mutex held. The buttons that were down before.
|
||||
Libraries::Pad::OrbisPadButtonDataOffset ApplyRemoteLocked(
|
||||
Libraries::Pad::OrbisPadButtonDataOffset buttons, const std::array<int, 6>& axes,
|
||||
bool touch_down, float touch_x, float touch_y);
|
||||
void ApplyTouch(int touchIndex, bool touchDown, float x, float y);
|
||||
/// Lets the right stick stand in for a finger on the touchpad (see controller.cpp).
|
||||
void UpdateStickTouch();
|
||||
|
||||
// The touchpad's first finger is the real one's (the window's events) or the right
|
||||
// stick's (a timer): what says which is kept together.
|
||||
std::mutex m_finger_mutex;
|
||||
bool m_finger_down = false;
|
||||
bool m_touchpad_noted = false;
|
||||
StickFinger m_stick_finger;
|
||||
// On a gamepad without a touchpad, buttons do its gestures as well (see PadGestures).
|
||||
PadGestures m_gestures;
|
||||
bool m_gesture_press = false;
|
||||
// The left shoulder button held, the stick turns the view instead (see ViewTurn).
|
||||
ViewTurn m_view_turn;
|
||||
bool m_stick_touch = false;
|
||||
float m_stick_touch_x = 0.5f;
|
||||
float m_stick_touch_y = 0.5f;
|
||||
std::atomic<bool> m_headset_plays{false};
|
||||
bool m_connected = false;
|
||||
int m_connected_count = 0;
|
||||
u8 m_touch_count = 0;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace Input {
|
||||
|
||||
/// A finger on a touchpad that something other than a finger moves (a stick, a button), in
|
||||
/// such a way that a title sees what a finger would have shown it.
|
||||
///
|
||||
/// A title reads its controller once for every frame it draws, and makes what it makes of a
|
||||
/// touch from what it saw then: where the finger was when it first saw it down, where when
|
||||
/// it last did, how far it got from one frame to the next. A real finger takes a tenth of a
|
||||
/// second and more over a swipe; a stick is at its end within a few hundredths, a button at
|
||||
/// once. A finger that lands and is at the far end of its way before the title has looked
|
||||
/// even once (at thirty frames a second it looks every 33 ms) never moved at all as far as
|
||||
/// the title can tell: ASTRO BOT Rescue Mission would not shoot its catapult for a stick
|
||||
/// pulled back quickly, because the pull began, for it, where it ended.
|
||||
///
|
||||
/// So this finger stays where it lands until the title has seen it there, goes to where it
|
||||
/// is wanted no faster than a finger flicks, and is only done once the title has seen it at
|
||||
/// its end as well.
|
||||
struct MovedFinger {
|
||||
/// How long it stays where it landed, in seconds: three frames of a title that draws
|
||||
/// thirty a second.
|
||||
static constexpr double Dwell = 0.09;
|
||||
/// How fast it goes, in widths of the pad a second: a brisk flick.
|
||||
static constexpr float Speed = 6.0f;
|
||||
/// How long it has to have been at its goal to count as arrived, in seconds.
|
||||
static constexpr double Hold = 0.08;
|
||||
|
||||
/// On the pad, 0 to 1: from its left to its right, from its far edge to its near one.
|
||||
float x{0.5f};
|
||||
float y{0.5f};
|
||||
|
||||
void Land(double time, float at_x, float at_y) {
|
||||
x = at_x;
|
||||
y = at_y;
|
||||
landed = time;
|
||||
moved = time;
|
||||
arrived = false;
|
||||
}
|
||||
|
||||
/// Goes on towards a goal, which may move. True once it has been there for `Hold`.
|
||||
bool Follow(double time, float goal_x, float goal_y) {
|
||||
const double passed = time - moved;
|
||||
moved = time;
|
||||
if (time - landed < Dwell) {
|
||||
return false;
|
||||
}
|
||||
const float dx = goal_x - x;
|
||||
const float dy = goal_y - y;
|
||||
const float distance = std::hypot(dx, dy);
|
||||
// (Called too seldom, it does not leap to make up for it.)
|
||||
const float step =
|
||||
Speed * static_cast<float>(passed < 0.0 ? 0.0 : passed > 0.05 ? 0.05 : passed);
|
||||
if (distance > step && distance > 1e-4f) {
|
||||
x += dx / distance * step;
|
||||
y += dy / distance * step;
|
||||
arrived = false;
|
||||
return false;
|
||||
}
|
||||
x = goal_x;
|
||||
y = goal_y;
|
||||
if (!arrived) {
|
||||
arrived = true;
|
||||
arrived_at = time;
|
||||
}
|
||||
return time - arrived_at >= Hold;
|
||||
}
|
||||
|
||||
/// How long ago it landed.
|
||||
double Down(double time) const {
|
||||
return time - landed;
|
||||
}
|
||||
|
||||
private:
|
||||
double landed{};
|
||||
double moved{};
|
||||
bool arrived{};
|
||||
double arrived_at{};
|
||||
};
|
||||
|
||||
} // namespace Input
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "moved_finger.h"
|
||||
|
||||
namespace Input {
|
||||
|
||||
/// What a finger does on a DualShock 4's touchpad, done by buttons: for controllers that
|
||||
/// have no touchpad (other gamepads, a headset's own controllers), where a title made for
|
||||
/// one wants more of it than a stick's finger does comfortably. Three things cover what
|
||||
/// ASTRO BOT Rescue Mission asks of the pad:
|
||||
/// - pressing it, for as long as a button is held (its water cannon and its machine gun
|
||||
/// fire while the pad is pressed);
|
||||
/// - a swipe forward, once for each press of a button (its hook is shot that way, its
|
||||
/// throwing stars thrown, its chests opened);
|
||||
/// - a pull back that is held for as long as a button is, and let go when the button is
|
||||
/// (the catapult at the end of each of its levels, and pulling on the hook's rope).
|
||||
/// Each is a finger a title can follow (see MovedFinger): it is where it lands for long
|
||||
/// enough to be seen there, and it is lifted for long enough to be seen lifted before it
|
||||
/// lands again.
|
||||
class PadGestures {
|
||||
public:
|
||||
struct Controls {
|
||||
bool press{};
|
||||
bool swipe{};
|
||||
bool pull{};
|
||||
};
|
||||
|
||||
struct Touch {
|
||||
bool down{};
|
||||
/// On the pad, 0 to 1: from its left to its right, from its far edge to its near one.
|
||||
float x{0.5f};
|
||||
float y{0.5f};
|
||||
/// The pad itself is pressed in.
|
||||
bool pressed{};
|
||||
};
|
||||
|
||||
/// `time` in seconds, from any moment. To be called at least some thirty times a second,
|
||||
/// whether a button is held or not.
|
||||
Touch Update(double time, const Controls& controls) {
|
||||
if (controls.swipe && !was.swipe && swipes < 2) {
|
||||
// (One that is asked for while another is under way comes after it.)
|
||||
++swipes;
|
||||
}
|
||||
was = controls;
|
||||
|
||||
switch (doing) {
|
||||
case Doing::Nothing:
|
||||
if (time - lifted < Apart) {
|
||||
break;
|
||||
}
|
||||
if (controls.press) {
|
||||
doing = Doing::Press;
|
||||
finger.Land(time, 0.5f, 0.5f);
|
||||
} else if (swipes != 0) {
|
||||
--swipes;
|
||||
doing = Doing::Swipe;
|
||||
finger.Land(time, 0.5f, SwipeFrom);
|
||||
} else if (controls.pull) {
|
||||
doing = Doing::Pull;
|
||||
let_go = false;
|
||||
finger.Land(time, 0.5f, PullFrom);
|
||||
}
|
||||
break;
|
||||
case Doing::Press:
|
||||
if (!controls.press && finger.Down(time) >= ShortestPress) {
|
||||
Lift(time);
|
||||
}
|
||||
break;
|
||||
case Doing::Swipe:
|
||||
if (finger.Follow(time, 0.5f, SwipeTo)) {
|
||||
Lift(time);
|
||||
}
|
||||
break;
|
||||
case Doing::Pull: {
|
||||
// Once let go it is let go, however soon the button is pulled again: the way
|
||||
// back is finished first, so that a quick pull of the button is a whole pull.
|
||||
let_go = let_go || !controls.pull;
|
||||
const bool there = finger.Follow(time, 0.5f, PullTo);
|
||||
if (let_go && there && finger.Down(time) >= ShortestPull) {
|
||||
Lift(time);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {doing != Doing::Nothing, finger.x, finger.y, doing == Doing::Press};
|
||||
}
|
||||
|
||||
/// Whatever was under way is over, and the finger is off the pad.
|
||||
void Reset() {
|
||||
doing = Doing::Nothing;
|
||||
swipes = 0;
|
||||
was = {};
|
||||
}
|
||||
|
||||
bool IsActive() const {
|
||||
return doing != Doing::Nothing;
|
||||
}
|
||||
|
||||
/// Where a swipe forward begins and ends, of the pad's depth from its far edge.
|
||||
static constexpr float SwipeFrom = 0.80f;
|
||||
static constexpr float SwipeTo = 0.12f;
|
||||
/// Where a pull back begins and ends.
|
||||
static constexpr float PullFrom = 0.20f;
|
||||
static constexpr float PullTo = 0.97f;
|
||||
/// The pad stays pressed this long at least, in seconds, however briefly the button was.
|
||||
static constexpr double ShortestPress = 0.12;
|
||||
/// A pull lasts this long at least. (That catapult wants more than a quarter second.)
|
||||
static constexpr double ShortestPull = 0.36;
|
||||
/// The finger is off the pad this long at least between two things.
|
||||
static constexpr double Apart = 0.08;
|
||||
|
||||
private:
|
||||
enum class Doing { Nothing, Press, Swipe, Pull };
|
||||
|
||||
void Lift(double time) {
|
||||
doing = Doing::Nothing;
|
||||
lifted = time;
|
||||
}
|
||||
|
||||
Doing doing{Doing::Nothing};
|
||||
Controls was;
|
||||
int swipes{};
|
||||
bool let_go{};
|
||||
double lifted{-1.0};
|
||||
MovedFinger finger;
|
||||
};
|
||||
|
||||
} // namespace Input
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
|
||||
namespace Input {
|
||||
|
||||
/// In a headset on a PC, the first player's controller can be played by two things at once: a
|
||||
/// gamepad connected to the PC, and the headset's own controllers. Whichever was used last
|
||||
/// plays; the other is left aside until it is used again. (A gamepad that only lies on the
|
||||
/// desk kept the headset's controllers from doing anything at all before.)
|
||||
///
|
||||
/// One case is left as it was: a "gamepad" that a streaming program makes of the headset's
|
||||
/// controllers themselves (Virtual Desktop can) says what they say, when they say it. Then
|
||||
/// it is the gamepad that plays: a use of the headset's controllers only counts once the
|
||||
/// gamepad has had nothing to say around the same time.
|
||||
///
|
||||
/// Times are in seconds of any one steady clock.
|
||||
class PadSource {
|
||||
public:
|
||||
/// How long a use of the headset's controllers waits for the gamepad not to say the same.
|
||||
static constexpr double Echo = 0.25;
|
||||
|
||||
/// A button of the gamepad was pressed, a stick or a trigger of it pushed, its touchpad
|
||||
/// touched. True when that takes the controller back from the headset's controllers.
|
||||
bool GamepadUsed(double now) {
|
||||
std::scoped_lock lock{mutex};
|
||||
gamepad_used = now;
|
||||
gamepad_ever = true;
|
||||
asked = -1.0;
|
||||
if (!headset) {
|
||||
return false;
|
||||
}
|
||||
headset = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Once for every frame of the headset. `gamepad`: the PC has a gamepad for the first
|
||||
/// player. `used`: one of the headset's controllers was used just now (a button that plays
|
||||
/// pressed, a stick pushed or pressed in: not what a hand does by resting on it).
|
||||
/// Whether the headset's controllers play.
|
||||
bool Update(double now, bool gamepad, bool used) {
|
||||
std::scoped_lock lock{mutex};
|
||||
if (!gamepad) {
|
||||
// Nothing else is there to play. (And a gamepad that comes later waits to be used.)
|
||||
headset = true;
|
||||
asked = -1.0;
|
||||
return true;
|
||||
}
|
||||
if (headset) {
|
||||
return true;
|
||||
}
|
||||
if (used && asked < 0.0) {
|
||||
asked = now;
|
||||
}
|
||||
if (asked >= 0.0) {
|
||||
if (gamepad_ever && gamepad_used >= asked - Echo) {
|
||||
asked = -1.0;
|
||||
} else if (now - asked >= Echo) {
|
||||
asked = -1.0;
|
||||
headset = true;
|
||||
}
|
||||
}
|
||||
return headset;
|
||||
}
|
||||
|
||||
bool HeadsetPlays() const {
|
||||
std::scoped_lock lock{mutex};
|
||||
return headset;
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex;
|
||||
bool headset{};
|
||||
bool gamepad_ever{};
|
||||
double gamepad_used{};
|
||||
double asked{-1.0};
|
||||
};
|
||||
|
||||
/// The one for the first player's controller.
|
||||
inline PadSource& FirstPadSource() {
|
||||
static PadSource source;
|
||||
return source;
|
||||
}
|
||||
|
||||
} // namespace Input
|
||||
@@ -19,6 +19,8 @@
|
||||
#include "core/vr/vr_runtime.h"
|
||||
#include "input/controller.h"
|
||||
#include "input/scripted_input.h"
|
||||
#include "input/pad_gestures.h"
|
||||
#include "input/stick_finger.h"
|
||||
|
||||
namespace Input {
|
||||
|
||||
@@ -27,6 +29,7 @@ namespace {
|
||||
using Buttons = Libraries::Pad::OrbisPadButtonDataOffset;
|
||||
|
||||
std::atomic<float> microphone_level{0.0f};
|
||||
std::atomic<bool> blowing{false};
|
||||
|
||||
struct Step {
|
||||
double start;
|
||||
@@ -42,6 +45,16 @@ struct Step {
|
||||
bool hands_lost{};
|
||||
// A finger on the touchpad, 0..1 across and down.
|
||||
std::optional<std::array<float, 2>> touch;
|
||||
// The right stick as it stands in for that finger (Input::StickFinger), -1..1 to the right
|
||||
// and towards the player.
|
||||
std::optional<std::array<float, 2>> finger;
|
||||
// The buttons that do the touchpad's gestures on a controller without one
|
||||
// (Input::PadGestures), held while the line lasts.
|
||||
PadGestures::Controls gestures;
|
||||
// The place a controller that nothing locates is held to be at: moved by so many metres
|
||||
// (right, up, towards the player), or switched between the standard one and the player's.
|
||||
std::optional<Core::Vr::Vec3> place_move;
|
||||
bool place_switch{};
|
||||
// What the microphone hears: noise of this loudness (root mean square, 1 is full scale).
|
||||
float microphone{};
|
||||
bool recenter{};
|
||||
@@ -151,6 +164,30 @@ std::vector<Step> ParseScript(const std::filesystem::path& script) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token.starts_with("finger=")) {
|
||||
const auto numbers = ParseNumbers(token.substr(7));
|
||||
if (numbers.size() >= 2) {
|
||||
step.finger = std::array<float, 2>{numbers[0], numbers[1]};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token == "gesture=press" || token == "gesture=swipe" || token == "gesture=pull") {
|
||||
step.gestures.press = step.gestures.press || token == "gesture=press";
|
||||
step.gestures.swipe = step.gestures.swipe || token == "gesture=swipe";
|
||||
step.gestures.pull = step.gestures.pull || token == "gesture=pull";
|
||||
continue;
|
||||
}
|
||||
if (token == "place=switch") {
|
||||
step.place_switch = true;
|
||||
continue;
|
||||
}
|
||||
if (token.starts_with("place=")) {
|
||||
const auto numbers = ParseNumbers(token.substr(6));
|
||||
if (numbers.size() >= 3) {
|
||||
step.place_move = Core::Vr::Vec3{numbers[0], numbers[1], numbers[2]};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
static constexpr std::array<std::string_view, 4> AxisNames = {"lx=", "ly=", "rx=",
|
||||
"ry="};
|
||||
for (size_t axis = 0; axis < AxisNames.size(); ++axis) {
|
||||
@@ -164,7 +201,7 @@ std::vector<Step> ParseScript(const std::filesystem::path& script) {
|
||||
return steps;
|
||||
}
|
||||
|
||||
void Replay(std::vector<Step> steps) {
|
||||
void Replay(std::vector<Step> steps, std::filesystem::path live_script) {
|
||||
Common::SetCurrentThreadName("shadPS4:ScriptedInput");
|
||||
const auto begin = std::chrono::steady_clock::now();
|
||||
double last_end = 0.0;
|
||||
@@ -179,7 +216,20 @@ void Replay(std::vector<Step> steps) {
|
||||
Buttons previous_buttons{Buttons::None};
|
||||
std::array<int, 6> previous_axes{128, 128, 128, 128, 0, 0};
|
||||
std::optional<std::array<float, 2>> previous_touch;
|
||||
StickFinger stick_finger;
|
||||
PadGestures gestures;
|
||||
std::filesystem::file_time_type live_stamp{};
|
||||
while (true) {
|
||||
if (!live_script.empty()) {
|
||||
std::error_code error;
|
||||
const auto stamp = std::filesystem::last_write_time(live_script, error);
|
||||
if (!error && stamp != live_stamp) {
|
||||
live_stamp = stamp;
|
||||
steps = ParseScript(live_script);
|
||||
started.resize(steps.size());
|
||||
LOG_INFO(Input, "Live script now has {} steps", steps.size());
|
||||
}
|
||||
}
|
||||
const double now =
|
||||
std::chrono::duration<double>(std::chrono::steady_clock::now() - begin).count();
|
||||
|
||||
@@ -189,6 +239,8 @@ void Replay(std::vector<Step> steps) {
|
||||
const Step* pad_step = nullptr;
|
||||
const Step* hands_step = nullptr;
|
||||
std::optional<std::array<float, 2>> touch;
|
||||
std::array<float, 2> finger{};
|
||||
PadGestures::Controls controls;
|
||||
float microphone = 0.0f;
|
||||
for (const Step& step : steps) {
|
||||
if (now >= step.start) {
|
||||
@@ -211,6 +263,12 @@ void Replay(std::vector<Step> steps) {
|
||||
if (step.touch) {
|
||||
touch = step.touch;
|
||||
}
|
||||
if (step.finger) {
|
||||
finger = *step.finger;
|
||||
}
|
||||
controls.press = controls.press || step.gestures.press;
|
||||
controls.swipe = controls.swipe || step.gestures.swipe;
|
||||
controls.pull = controls.pull || step.gestures.pull;
|
||||
microphone = std::max(microphone, step.microphone);
|
||||
buttons |= step.buttons;
|
||||
for (size_t axis = 0; axis < step.sticks.size(); ++axis) {
|
||||
@@ -220,6 +278,18 @@ void Replay(std::vector<Step> steps) {
|
||||
}
|
||||
}
|
||||
microphone_level.store(microphone, std::memory_order_relaxed);
|
||||
// What buttons do on the touchpad comes before what the stick does there.
|
||||
if (const auto made = gestures.Update(now, controls); made.down && !touch) {
|
||||
touch = std::array<float, 2>{made.x, made.y};
|
||||
if (made.pressed) {
|
||||
buttons |= Buttons::TouchPad;
|
||||
}
|
||||
stick_finger.Reset();
|
||||
} else if (const auto stick = stick_finger.Update(now, finger[0], finger[1]);
|
||||
stick.down && !touch) {
|
||||
// (A stick at rest between its lines: that is what lifts its finger.)
|
||||
touch = std::array<float, 2>{stick.x, stick.y};
|
||||
}
|
||||
auto& vr = Core::Vr::Runtime::Instance();
|
||||
if (head_step != nullptr) {
|
||||
vr.UpdateHead({.pose = *head_step->head, .tracked = true});
|
||||
@@ -234,6 +304,12 @@ void Replay(std::vector<Step> steps) {
|
||||
// The player asks for the view to be reset, wherever the head is right now.
|
||||
vr.RequestRecenter();
|
||||
}
|
||||
if (steps[i].place_move) {
|
||||
vr.MoveOwnPadPlace(*steps[i].place_move);
|
||||
}
|
||||
if (steps[i].place_switch) {
|
||||
vr.SwitchPadPlace();
|
||||
}
|
||||
if (steps[i].worn) {
|
||||
LOG_INFO(Input, "Scripted: the headset is {}", *steps[i].worn ? "put on"
|
||||
: "taken off");
|
||||
@@ -271,7 +347,7 @@ void Replay(std::vector<Step> steps) {
|
||||
previous_axes = axes;
|
||||
previous_touch = touch;
|
||||
}
|
||||
if (now > last_end + 1.0) {
|
||||
if (live_script.empty() && now > last_end + 1.0) {
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
@@ -283,7 +359,19 @@ void Replay(std::vector<Step> steps) {
|
||||
} // namespace
|
||||
|
||||
float ScriptedMicrophoneLevel() {
|
||||
return microphone_level.load(std::memory_order_relaxed);
|
||||
// (0.36: a little above what ASTRO BOT takes for blowing as hard as can be.)
|
||||
return std::max(microphone_level.load(std::memory_order_relaxed),
|
||||
blowing.load(std::memory_order_relaxed) ? 0.36f : 0.0f);
|
||||
}
|
||||
|
||||
void SetBlowing(bool blowing_now) {
|
||||
if (blowing.exchange(blowing_now, std::memory_order_relaxed) != blowing_now && blowing_now) {
|
||||
static std::atomic<bool> said{false};
|
||||
if (!said.exchange(true)) {
|
||||
LOG_INFO(Input,
|
||||
"Blowing with buttons: the game hears it as blowing into the microphone");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void StartScriptedInput(const std::filesystem::path& script) {
|
||||
@@ -293,7 +381,8 @@ void StartScriptedInput(const std::filesystem::path& script) {
|
||||
return;
|
||||
}
|
||||
LOG_INFO(Input, "Replaying {} input steps from {}", steps.size(), script.string());
|
||||
std::thread{Replay, std::move(steps)}.detach();
|
||||
const char* live = std::getenv("SHADPS4_LIVE_INPUT");
|
||||
std::thread{Replay, std::move(steps), std::filesystem::path{live ? live : ""}}.detach();
|
||||
}
|
||||
|
||||
} // namespace Input
|
||||
@@ -16,14 +16,24 @@ namespace Input {
|
||||
/// "padrot=yaw,pitch,roll"; those poses stay in effect until a later line replaces them.
|
||||
/// "hands=x,y,z[,heading]" stands for a host that sees where the controller is held but not how
|
||||
/// it is turned ("hands=off" for losing sight of it), "touch=x,y" (0..1) for a finger on the
|
||||
/// touchpad while the line lasts, "mic=level" for noise of that loudness in the microphone
|
||||
/// touchpad while the line lasts, "finger=x,y" (-1..1) for the right stick as it moves that
|
||||
/// finger, "gesture=press", "gesture=swipe" or "gesture=pull" for the buttons that do the
|
||||
/// touchpad's gestures on a controller without one, held while the line lasts,
|
||||
/// "mic=level" for noise of that loudness in the microphone
|
||||
/// (root mean square, 1 is full scale: 0.35 is what a title takes for blowing hard),
|
||||
/// "recenter" for the player asking for the view to be reset where the head is at that moment,
|
||||
/// "worn=0" / "worn=1" for the headset being taken off and put back on.
|
||||
/// Lines starting with '#' are ignored. Times count from when the script is started.
|
||||
void StartScriptedInput(const std::filesystem::path& script);
|
||||
|
||||
/// The loudness a running script wants the microphone to hear right now, 0 for none.
|
||||
/// The loudness a running script wants the microphone to hear right now, 0 for none. Also
|
||||
/// what a title takes for blowing hard while the player holds the buttons that stand for it
|
||||
/// (SetBlowing).
|
||||
float ScriptedMicrophoneLevel();
|
||||
|
||||
/// The player blows without a microphone: with buttons (the PS button and square on a gamepad,
|
||||
/// X and Y together on a headset's controllers), for where the microphone is not to be had,
|
||||
/// gives too little, or blowing is not what the player wants to do.
|
||||
void SetBlowing(bool blowing);
|
||||
|
||||
} // namespace Input
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
|
||||
#include "moved_finger.h"
|
||||
|
||||
namespace Input {
|
||||
|
||||
/// A finger on a touchpad, moved by a stick: for controllers that have no touchpad, where a
|
||||
/// title made for one has its players swipe and drag. Where the stick points is where the
|
||||
/// finger goes, with what a stick needs and a finger does not:
|
||||
/// - The finger comes down behind the middle of the pad, on the side the stick comes from,
|
||||
/// and is dragged from there to where the stick points: a push of the stick is a swipe
|
||||
/// over all of the way it goes, and more.
|
||||
/// - It is a finger a title can follow (see MovedFinger): it stays where it came down
|
||||
/// until the title has seen it there, and goes no faster than a finger flicks. A stick
|
||||
/// is at its end before a title that draws thirty frames a second has looked once.
|
||||
/// - A stick that is let go flies back to its centre; a finger that is lifted does not go
|
||||
/// back to where it came from first. A title that goes by how far the finger was dragged
|
||||
/// when it lifts (ASTRO BOT Rescue Mission pulls a catapult back that way at the end of
|
||||
/// every level, and shoots when the finger lets go) would find nothing pulled at all. So
|
||||
/// the finger goes to where the stick was before it started back, and lifts from there
|
||||
/// once the stick has come to rest, however briefly the stick was out.
|
||||
/// - A stick may be moved through its centre, from one side to the other, without the finger
|
||||
/// lifting on the way: a drag over the whole pad.
|
||||
/// - The stick does not have to be pushed all the way for the finger to reach the pad's edge,
|
||||
/// and the finger stays down for a moment at least, however briefly the stick was pushed.
|
||||
/// (That catapult only shoots for a finger that was down for more than a quarter of a
|
||||
/// second and dragged over more than four tenths of the pad.)
|
||||
/// - A stick that never comes to rest near its centre (a worn one that drifts) moves no
|
||||
/// finger: it would touch the pad for good, and a real finger on a real touchpad would
|
||||
/// count for nothing beside it.
|
||||
class StickFinger {
|
||||
public:
|
||||
struct Touch {
|
||||
bool down;
|
||||
/// On the pad, 0 to 1: from its left to its right, from its far edge to its near one.
|
||||
float x;
|
||||
float y;
|
||||
};
|
||||
|
||||
/// `time` in seconds, from any moment; `x` to the right and `y` towards the player of the
|
||||
/// stick's centre, -1 to 1 each. To be called at least some thirty times a second.
|
||||
Touch Update(double time, float x, float y) {
|
||||
const float r = std::hypot(x, y);
|
||||
const float previous_r = count != 0 ? At(0).r : r;
|
||||
Push({time, x, y, r});
|
||||
|
||||
if (!down) {
|
||||
if (r <= Rest) {
|
||||
armed = true;
|
||||
}
|
||||
if (!armed || r <= Start) {
|
||||
return Lifted();
|
||||
}
|
||||
down = true;
|
||||
armed = false;
|
||||
held = false;
|
||||
goal_x = x;
|
||||
goal_y = y;
|
||||
finger.Land(time, 0.5f - x / r * Back, 0.5f - y / r * Back);
|
||||
return Touching();
|
||||
}
|
||||
|
||||
// The furthest the stick has been out in the last moments, and whether it is on its
|
||||
// way back from there in a hurry.
|
||||
const Sample* peak = &At(0);
|
||||
for (size_t back = 1; back < count && time - At(back).time <= Window; ++back) {
|
||||
if (At(back).r > peak->r) {
|
||||
peak = &At(back);
|
||||
}
|
||||
}
|
||||
const bool flying_back = r < peak->r - Drop && r <= previous_r + 0.01f;
|
||||
const bool inside = r <= Start;
|
||||
if (!inside && !flying_back) {
|
||||
goal_x = x;
|
||||
goal_y = y;
|
||||
held = false;
|
||||
} else if (flying_back && !held) {
|
||||
goal_x = peak->x;
|
||||
goal_y = peak->y;
|
||||
held = true;
|
||||
}
|
||||
const bool there = finger.Follow(time, OnPad(goal_x), OnPad(goal_y));
|
||||
if (inside && IsStill(time) && there && finger.Down(time) >= ShortestTouch) {
|
||||
down = false;
|
||||
armed = r <= Rest;
|
||||
return Lifted();
|
||||
}
|
||||
return Touching();
|
||||
}
|
||||
|
||||
/// The finger is lifted, and the stick has to come to rest before it touches again.
|
||||
void Reset() {
|
||||
down = false;
|
||||
armed = false;
|
||||
held = false;
|
||||
count = 0;
|
||||
}
|
||||
|
||||
bool IsDown() const {
|
||||
return down;
|
||||
}
|
||||
|
||||
/// The stick counts as at rest within this of its centre, and the finger comes down once
|
||||
/// it is further out than that.
|
||||
static constexpr float Rest = 0.15f;
|
||||
static constexpr float Start = 0.25f;
|
||||
/// How far behind the pad's middle the finger comes down, of the pad's size.
|
||||
static constexpr float Back = 0.25f;
|
||||
/// How far from the pad's centre the stick would put the finger if the pad went on, of
|
||||
/// the pad's size: the finger is at the pad's edge with the stick four fifths out.
|
||||
static constexpr float Reach = 0.62f;
|
||||
/// A touch lasts this long at least, in seconds.
|
||||
static constexpr double ShortestTouch = 0.36;
|
||||
|
||||
private:
|
||||
struct Sample {
|
||||
double time;
|
||||
float x;
|
||||
float y;
|
||||
float r;
|
||||
};
|
||||
|
||||
/// Flying back: nearer the centre by this much than the stick was within that long.
|
||||
static constexpr float Drop = 0.12f;
|
||||
static constexpr double Window = 0.045;
|
||||
/// Come to rest: near the centre and hardly moved for this long.
|
||||
static constexpr double Still = 0.025;
|
||||
static constexpr float StillMove = 0.05f;
|
||||
|
||||
/// The sample taken `back` samples ago, 0 for the newest.
|
||||
const Sample& At(size_t back) const {
|
||||
return samples[(newest + samples.size() - back) % samples.size()];
|
||||
}
|
||||
|
||||
void Push(const Sample& sample) {
|
||||
newest = (newest + 1) % samples.size();
|
||||
samples[newest] = sample;
|
||||
if (count < samples.size()) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsStill(double time) const {
|
||||
const Sample& now = At(0);
|
||||
for (size_t back = 1; back < count; ++back) {
|
||||
const Sample& then = At(back);
|
||||
if (std::abs(then.x - now.x) > StillMove || std::abs(then.y - now.y) > StillMove) {
|
||||
return false;
|
||||
}
|
||||
if (time - then.time >= Still) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Where on the pad the stick, that far out, wants the finger.
|
||||
static float OnPad(float value) {
|
||||
const float at = 0.5f + value * Reach;
|
||||
return at < 0.0f ? 0.0f : at > 1.0f ? 1.0f : at;
|
||||
}
|
||||
|
||||
Touch Touching() const {
|
||||
return {true, finger.x, finger.y};
|
||||
}
|
||||
|
||||
Touch Lifted() const {
|
||||
return {false, finger.x, finger.y};
|
||||
}
|
||||
|
||||
std::array<Sample, 32> samples{};
|
||||
size_t newest{};
|
||||
size_t count{};
|
||||
bool down{};
|
||||
bool armed{};
|
||||
bool held{};
|
||||
float goal_x{};
|
||||
float goal_y{};
|
||||
MovedFinger finger;
|
||||
};
|
||||
|
||||
} // namespace Input
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace Input {
|
||||
|
||||
/// Turning the view by steps, for players who sit where they cannot turn round: a title made
|
||||
/// for a headset has things behind the player, and expects them to look. A button the title
|
||||
/// has no use for in play is held, and each flick of the right stick to a side then turns the
|
||||
/// view one step that way (Core::Vr::Runtime::TurnView) instead of moving a finger over the
|
||||
/// touchpad.
|
||||
class ViewTurn {
|
||||
public:
|
||||
/// How far to a side the stick has to go, and how near the middle it has to come back
|
||||
/// before it can turn again.
|
||||
static constexpr float Far = 0.7f;
|
||||
static constexpr float Near = 0.35f;
|
||||
|
||||
/// `held`: the button. `x`: the stick, -1 at its left end to 1 at its right.
|
||||
/// 1: a step to the right now, -1: one to the left, 0: nothing.
|
||||
int Update(bool held, float x) {
|
||||
if (!held) {
|
||||
ready = false;
|
||||
return 0;
|
||||
}
|
||||
// (A stick that is at a side already when the button comes down turns nothing.)
|
||||
if (std::abs(x) < Near) {
|
||||
ready = true;
|
||||
return 0;
|
||||
}
|
||||
if (ready && std::abs(x) > Far) {
|
||||
ready = false;
|
||||
return x > 0.0f ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
ready = false;
|
||||
}
|
||||
|
||||
private:
|
||||
bool ready{};
|
||||
};
|
||||
|
||||
} // namespace Input
|
||||
@@ -24,8 +24,10 @@
|
||||
#include "core/user_settings.h"
|
||||
#include "imgui/renderer/imgui_core.h"
|
||||
#include "input/controller.h"
|
||||
#include "input/scripted_input.h"
|
||||
#include "input/input_handler.h"
|
||||
#include "input/input_mouse.h"
|
||||
#include "input/pad_source.h"
|
||||
#include "core/vr/vr_runtime.h"
|
||||
#include "sdl_window.h"
|
||||
#include "video_core/renderdoc.h"
|
||||
@@ -179,6 +181,66 @@ WindowSDL::WindowSDL(s32 width_, s32 height_, Input::GameControllers* controller
|
||||
desc.name = i == 0 ? "Test Xbox 360 Controller" : "Test DualSense";
|
||||
const SDL_JoystickID id = SDL_AttachVirtualJoystick(&desc);
|
||||
LOG_INFO(Input, "Test gamepad {} plugged in: {}", desc.name, id != 0 ? "yes" : SDL_GetError());
|
||||
// SHADPS4_TEST_VIRTUAL_PRESS=<seconds>[,<seconds>...]: the first of them has its
|
||||
// cross button pressed for a moment, so long after the start.
|
||||
// SHADPS4_TEST_VIRTUAL_TURN=<seconds>[,...]: its left shoulder button held and
|
||||
// its right stick flicked to the right (to the left for a negative time).
|
||||
if (i == 0 && id != 0) {
|
||||
static SDL_Joystick* test_pad = nullptr;
|
||||
test_pad = SDL_OpenJoystick(id);
|
||||
const auto each = [](const char* name, auto&& with) {
|
||||
const char* at = std::getenv(name);
|
||||
while (at != nullptr && *at != '\0') {
|
||||
with(std::atof(at));
|
||||
at = std::strchr(at, ',');
|
||||
at = at != nullptr ? at + 1 : nullptr;
|
||||
}
|
||||
};
|
||||
const auto later = [](double seconds, SDL_TimerCallback what) {
|
||||
if (SDL_AddTimer(static_cast<Uint32>(seconds * 1000.0), what, nullptr) == 0) {
|
||||
LOG_ERROR(Input, "Test gamepad: no timer: {}", SDL_GetError());
|
||||
}
|
||||
};
|
||||
each("SHADPS4_TEST_VIRTUAL_PRESS", [&](double seconds) {
|
||||
later(seconds, [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
const bool done =
|
||||
SDL_SetJoystickVirtualButton(test_pad, SDL_GAMEPAD_BUTTON_SOUTH, true);
|
||||
LOG_INFO(Input, "Test gamepad: cross pressed: {}", done ? "yes" : SDL_GetError());
|
||||
return 0;
|
||||
});
|
||||
later(seconds + 0.3, [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
SDL_SetJoystickVirtualButton(test_pad, SDL_GAMEPAD_BUTTON_SOUTH, false);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
each("SHADPS4_TEST_VIRTUAL_TURN", [&](double seconds) {
|
||||
const bool left = seconds < 0.0;
|
||||
seconds = std::abs(seconds);
|
||||
later(seconds, [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
const bool done = SDL_SetJoystickVirtualButton(
|
||||
test_pad, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, true);
|
||||
LOG_INFO(Input, "Test gamepad: left shoulder button held for a turn: {}",
|
||||
done ? "yes" : SDL_GetError());
|
||||
return 0;
|
||||
});
|
||||
later(seconds + 0.2, left ? [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
SDL_SetJoystickVirtualAxis(test_pad, SDL_GAMEPAD_AXIS_RIGHTX, -32767);
|
||||
return 0;
|
||||
} : [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
SDL_SetJoystickVirtualAxis(test_pad, SDL_GAMEPAD_AXIS_RIGHTX, 32767);
|
||||
return 0;
|
||||
});
|
||||
later(seconds + 0.4, [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
SDL_SetJoystickVirtualAxis(test_pad, SDL_GAMEPAD_AXIS_RIGHTX, 0);
|
||||
return 0;
|
||||
});
|
||||
later(seconds + 0.6, [](void*, SDL_TimerID, Uint32) -> Uint32 {
|
||||
SDL_SetJoystickVirtualButton(test_pad, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER,
|
||||
false);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -461,12 +523,111 @@ void WindowSDL::OnGamepadEvent(const SDL_Event* event) {
|
||||
event->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
Input::InputEvent input_event = Input::InputBinding::GetInputEventFromSDLEvent(*event);
|
||||
|
||||
// The PS button is nothing a title ever sees. In a headset it resets the view.
|
||||
if ((event->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN ||
|
||||
event->type == SDL_EVENT_GAMEPAD_BUTTON_UP) &&
|
||||
event->gbutton.button == SDL_GAMEPAD_BUTTON_GUIDE) {
|
||||
Core::Vr::Runtime::Instance().NotePadButton(Core::Vr::Runtime::PadButton::Home,
|
||||
event->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN);
|
||||
// In a headset, the first player's gamepad and the headset's own controllers take turns:
|
||||
// whichever was used last plays (see Input::PadSource). A button pressed, a stick or a
|
||||
// trigger pushed, the touchpad touched is the gamepad being used.
|
||||
{
|
||||
SDL_JoystickID which = 0;
|
||||
bool used = false;
|
||||
switch (event->type) {
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
which = event->gbutton.which;
|
||||
used = true;
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_AXIS_MOTION:
|
||||
which = event->gaxis.which;
|
||||
used = std::abs(static_cast<int>(event->gaxis.value)) > 16000;
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN:
|
||||
which = event->gtouchpad.which;
|
||||
used = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
auto* const first = controllers[0];
|
||||
if (used && first->m_sdl_gamepad != nullptr &&
|
||||
SDL_GetGamepadFromID(which) == first->m_sdl_gamepad) {
|
||||
const double now = std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now().time_since_epoch())
|
||||
.count();
|
||||
if (Input::FirstPadSource().GamepadUsed(now)) {
|
||||
first->SetHeadsetPlays(false);
|
||||
LOG_INFO(Input, "Controller 1 was used: it plays again, and the headset's "
|
||||
"controllers are left aside until they are used");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The PS button is nothing a title ever sees. In a headset, pressed and let go, it resets
|
||||
// the view. Held, it has the D-pad move the place a controller that nothing locates is
|
||||
// held to be at (up, down, left, right; L1 nearer, R1 farther; two centimetres a press),
|
||||
// triangle switch between that place and the standard one, and square blow into the
|
||||
// microphone for as long as it is held. None of that reaches the title.
|
||||
if (event->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN ||
|
||||
event->type == SDL_EVENT_GAMEPAD_BUTTON_UP) {
|
||||
static bool held = false;
|
||||
static bool used = false;
|
||||
static u32 kept_back = 0;
|
||||
const bool down = event->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
const u8 button = event->gbutton.button;
|
||||
auto& runtime = Core::Vr::Runtime::Instance();
|
||||
if (button == SDL_GAMEPAD_BUTTON_GUIDE) {
|
||||
if (down) {
|
||||
held = true;
|
||||
used = false;
|
||||
} else {
|
||||
if (held && !used) {
|
||||
runtime.NotePadButton(Core::Vr::Runtime::PadButton::Home, true);
|
||||
runtime.NotePadButton(Core::Vr::Runtime::PadButton::Home, false);
|
||||
}
|
||||
held = false;
|
||||
}
|
||||
} else if (button < 32 && !down && (kept_back & (1u << button)) != 0) {
|
||||
// Let go of after it did something else than the title would have seen.
|
||||
kept_back &= ~(1u << button);
|
||||
if (button == SDL_GAMEPAD_BUTTON_WEST) {
|
||||
Input::SetBlowing(false);
|
||||
}
|
||||
return;
|
||||
} else if (held && down && button < 32) {
|
||||
static constexpr float Step = 0.02f;
|
||||
bool taken = true;
|
||||
switch (button) {
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_UP:
|
||||
runtime.MoveOwnPadPlace({0.0f, Step, 0.0f});
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
|
||||
runtime.MoveOwnPadPlace({0.0f, -Step, 0.0f});
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
|
||||
runtime.MoveOwnPadPlace({-Step, 0.0f, 0.0f});
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
|
||||
runtime.MoveOwnPadPlace({Step, 0.0f, 0.0f});
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
|
||||
runtime.MoveOwnPadPlace({0.0f, 0.0f, Step});
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
|
||||
runtime.MoveOwnPadPlace({0.0f, 0.0f, -Step});
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_NORTH:
|
||||
runtime.SwitchPadPlace();
|
||||
break;
|
||||
case SDL_GAMEPAD_BUTTON_WEST:
|
||||
Input::SetBlowing(true);
|
||||
break;
|
||||
default:
|
||||
taken = false;
|
||||
break;
|
||||
}
|
||||
if (taken) {
|
||||
used = true;
|
||||
kept_back |= 1u << button;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the touchpad button shouldn't be rebound to anything else,
|
||||
|
||||
@@ -475,6 +475,10 @@ void EmitContext::DefineInputs() {
|
||||
break;
|
||||
case LogicalStage::Geometry: {
|
||||
primitive_id = DefineVariable(U32[1], spv::BuiltIn::PrimitiveId, spv::StorageClass::Input);
|
||||
if (info.loads.GetAny(IR::Attribute::InvocationId)) {
|
||||
invocation_id =
|
||||
DefineVariable(U32[1], spv::BuiltIn::InvocationId, spv::StorageClass::Input);
|
||||
}
|
||||
const auto gl_per_vertex =
|
||||
Name(TypeStruct(F32[4], F32[1], TypeArray(F32[1], ConstU32(1u))), "gl_PerVertex");
|
||||
MemberName(gl_per_vertex, 0, "gl_Position");
|
||||
|
||||
@@ -226,16 +226,16 @@ void Translator::EmitPrologue(IR::Block* first_block) {
|
||||
}
|
||||
break;
|
||||
case LogicalStage::Geometry:
|
||||
switch (runtime_info.gs_info.out_primitive[0]) {
|
||||
case AmdGpu::GsOutputPrimitiveType::TriangleStrip:
|
||||
ir.SetVectorReg(IR::VectorReg::V3, ir.Imm32(2u)); // vertex 2
|
||||
case AmdGpu::GsOutputPrimitiveType::LineStrip:
|
||||
ir.SetVectorReg(IR::VectorReg::V1, ir.Imm32(1u)); // vertex 1
|
||||
default:
|
||||
ir.SetVectorReg(IR::VectorReg::V0, ir.Imm32(0u)); // vertex 0
|
||||
break;
|
||||
}
|
||||
// GCN's GS input ABI is independent of the output topology. The ring access pass
|
||||
// uses these relative vertex offsets after the shader converts them to bytes.
|
||||
ir.SetVectorReg(IR::VectorReg::V0, ir.Imm32(0u)); // vertex 0
|
||||
ir.SetVectorReg(IR::VectorReg::V1, ir.Imm32(1u)); // vertex 1
|
||||
ir.SetVectorReg(IR::VectorReg::V2, ir.GetAttributeU32(IR::Attribute::PrimitiveId));
|
||||
ir.SetVectorReg(IR::VectorReg::V3, ir.Imm32(2u)); // vertex 2
|
||||
ir.SetVectorReg(IR::VectorReg::V4, ir.Imm32(3u)); // vertex 3 (adjacency)
|
||||
ir.SetVectorReg(IR::VectorReg::V5, ir.Imm32(4u)); // vertex 4 (adjacency)
|
||||
ir.SetVectorReg(IR::VectorReg::V6, ir.Imm32(5u)); // vertex 5 (adjacency)
|
||||
ir.SetVectorReg(IR::VectorReg::V7, ir.GetAttributeU32(IR::Attribute::InvocationId));
|
||||
break;
|
||||
default:
|
||||
UNREACHABLE_MSG("Unknown shader stage");
|
||||
|
||||
@@ -125,13 +125,20 @@ struct ImageResource {
|
||||
}
|
||||
|
||||
u32 NumBindings(const auto& info) const {
|
||||
// Only an image whose mips are indexed in the shader needs its sharp for this: a
|
||||
// pipeline made from the cache as the emulator starts has no sharps to read (the
|
||||
// title's memory holds nothing yet).
|
||||
if (mip_fallback_mode != MipStorageFallbackMode::DynamicIndex) {
|
||||
return 1;
|
||||
}
|
||||
const AmdGpu::Image tsharp = GetSharp(info);
|
||||
return (mip_fallback_mode == MipStorageFallbackMode::DynamicIndex)
|
||||
? (tsharp.last_level - tsharp.base_level + 1)
|
||||
: 1;
|
||||
return tsharp.last_level - tsharp.base_level + 1;
|
||||
}
|
||||
};
|
||||
using ImageResourceList = boost::container::small_vector<ImageResource, NUM_IMAGES>;
|
||||
// (Fixed capacity, like the other lists: the pipeline cache stores Shader::InfoPersistent as its
|
||||
// bytes, and a small_vector's bytes hold a pointer to its elements, which pointed into the
|
||||
// process that wrote the cache.)
|
||||
using ImageResourceList = boost::container::static_vector<ImageResource, NUM_IMAGES>;
|
||||
|
||||
struct SamplerResource {
|
||||
u32 sharp_idx;
|
||||
@@ -145,7 +152,7 @@ struct SamplerResource {
|
||||
: info.template ReadUdSharp<AmdGpu::Sampler>(sharp_idx);
|
||||
}
|
||||
};
|
||||
using SamplerResourceList = boost::container::small_vector<SamplerResource, NUM_SAMPLERS>;
|
||||
using SamplerResourceList = boost::container::static_vector<SamplerResource, NUM_SAMPLERS>;
|
||||
|
||||
struct FMaskResource {
|
||||
u32 sharp_idx;
|
||||
|
||||
@@ -147,8 +147,11 @@ struct GeometryRuntimeInfo {
|
||||
u64 vs_copy_hash;
|
||||
|
||||
bool operator==(const GeometryRuntimeInfo& other) const {
|
||||
return num_outputs == other.num_outputs && outputs == other.outputs && num_invocations &&
|
||||
other.num_invocations && output_vertices == other.output_vertices &&
|
||||
return num_outputs == other.num_outputs && outputs == other.outputs &&
|
||||
num_invocations == other.num_invocations &&
|
||||
output_vertices == other.output_vertices &&
|
||||
in_vertex_data_size == other.in_vertex_data_size &&
|
||||
out_vertex_data_size == other.out_vertex_data_size && mode == other.mode &&
|
||||
in_primitive == other.in_primitive &&
|
||||
std::ranges::equal(out_primitive, other.out_primitive) &&
|
||||
vs_copy_hash == other.vs_copy_hash;
|
||||
|
||||
@@ -277,7 +277,7 @@ void PostProcessingPass::Render(vk::CommandBuffer cmdbuf, std::span<const Region
|
||||
.minDepth = 0.0f,
|
||||
.maxDepth = 1.0f,
|
||||
});
|
||||
cmdbuf.setScissor(0, region.area);
|
||||
cmdbuf.setScissor(0, region.clip.value_or(region.area));
|
||||
|
||||
if (uses_push_descriptors) {
|
||||
const vk::DescriptorImageInfo image_info{
|
||||
|
||||
@@ -35,6 +35,7 @@ public:
|
||||
struct Region {
|
||||
vk::ImageView input;
|
||||
vk::Rect2D area;
|
||||
std::optional<vk::Rect2D> clip;
|
||||
};
|
||||
|
||||
// Frame marker: a row of black and white blocks stamped along the top-left edge of a frame.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cstdlib>
|
||||
#ifdef ENABLE_BACHATA_RUNTIME
|
||||
#ifdef __linux__
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#include <boost/container/static_vector.hpp>
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -766,6 +836,8 @@ void Instance::CreateAllocator() {
|
||||
// The default block is a quarter of a gigabyte, which the kernel drivers of phones and
|
||||
// headsets back with real pages whether anything has been placed in it or not.
|
||||
.preferredLargeHeapBlockSize = 32ULL << 20,
|
||||
#else
|
||||
.preferredLargeHeapBlockSize = driver_id == vk::DriverId::eMesaTurnip ? 32ULL << 20 : 0,
|
||||
#endif
|
||||
.pVulkanFunctions = &functions,
|
||||
.instance = *instance,
|
||||
@@ -844,7 +916,12 @@ void Instance::CollectPhysicalMemoryInfo() {
|
||||
const s64 available_memory = static_cast<s64>(total_memory_budget - device_initial_usage);
|
||||
total_memory_budget =
|
||||
static_cast<u64>(std::max<s64>(available_memory - 8_GB, static_cast<s64>(local_memory)));
|
||||
#ifdef ENABLE_BACHATA_RUNTIME
|
||||
#ifdef __linux__
|
||||
#ifndef ENABLE_BACHATA_RUNTIME
|
||||
if (driver_id != vk::DriverId::eMesaTurnip) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// A phone or headset: the "heap" the driver reports is most of the system's memory, which
|
||||
// the guest's own memory, the system and everything else running have to fit into as well.
|
||||
// A quarter of what the machine has is what the GPU caches may plan with.
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -454,6 +454,18 @@ bool PipelineCache::RefreshGraphicsKey() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Targets this pass of a draw leaves out (LeaveTargetsOut): the shaders are the ones of
|
||||
// the whole draw, what they write there goes nowhere.
|
||||
if (targets_left_out != 0) {
|
||||
key.mrt_mask &= ~targets_left_out;
|
||||
key.num_color_attachments = std::bit_width(key.mrt_mask);
|
||||
for (s32 cb = 0; cb < AmdGpu::NUM_COLOR_BUFFERS; ++cb) {
|
||||
if ((targets_left_out & (1u << cb)) != 0) {
|
||||
std::memset(&key.color_buffers[cb], 0, sizeof(Shader::PsColorBuffer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass to mask out render targets not written by shader and fill remaining info
|
||||
u8 color_samples = 0;
|
||||
bool all_color_samples_same = true;
|
||||
|
||||
@@ -78,6 +78,13 @@ public:
|
||||
|
||||
const GraphicsPipeline* GetGraphicsPipeline();
|
||||
|
||||
/// The colour targets (a bit each) that the graphics pipelines asked for from now on leave
|
||||
/// out, as if the draw had nothing bound there. For a draw that has one surface bound as
|
||||
/// more than one of its targets, which is drawn once for each (Rasterizer::Draw).
|
||||
void LeaveTargetsOut(u8 targets) {
|
||||
targets_left_out = targets;
|
||||
}
|
||||
|
||||
const ComputePipeline* GetComputePipeline();
|
||||
|
||||
using Result = std::tuple<const Shader::Info*, vk::ShaderModule,
|
||||
@@ -129,7 +136,10 @@ private:
|
||||
std::array<const Shader::Info*, MaxShaderStages> infos{};
|
||||
std::array<vk::ShaderModule, MaxShaderStages> modules{};
|
||||
std::optional<Shader::Gcn::FetchShaderData> fetch_shader{};
|
||||
// Preloading must retain vertex fetch data across later shader stages.
|
||||
std::optional<Shader::Gcn::FetchShaderData> preload_fetch_shader{};
|
||||
GraphicsPipelineKey graphics_key{};
|
||||
u8 targets_left_out{};
|
||||
ComputePipelineKey compute_key{};
|
||||
u32 num_new_pipelines{}; // new pipelines added to the cache since the game start
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
|
||||
namespace Serialization {
|
||||
/* You should increment versions below once corresponding serialization scheme is changed. */
|
||||
static constexpr u32 ShaderBinaryVersion = 2u;
|
||||
static constexpr u32 ShaderMetaVersion = 2u;
|
||||
static constexpr u32 PipelineKeyVersion = 2u;
|
||||
// Preserve upstream 0.20 geometry-input ABI and invocation-ID correction.
|
||||
static constexpr u32 ShaderBinaryVersion = 3u;
|
||||
// Inline image/sampler lists replace pointer-containing small vectors.
|
||||
static constexpr u32 ShaderMetaVersion = 3u;
|
||||
static constexpr u32 PipelineKeyVersion = 3u;
|
||||
} // namespace Serialization
|
||||
|
||||
namespace Vulkan {
|
||||
@@ -142,6 +144,16 @@ bool ComputePipeline::SerializationSupport::Deserialize(Serialization::Archive&
|
||||
return true;
|
||||
}
|
||||
|
||||
// A pipeline whose descriptor layout depends on the title's image sharps (an image whose mips
|
||||
// the shader indexes: a binding for each) cannot be made before the title runs: it is not
|
||||
// preloaded but compiled when the title first uses it. (Reading the sharps as the emulator
|
||||
// starts read the title's memory before there was any.)
|
||||
static bool UsesSharpsToLayOut(const Shader::Info& info) {
|
||||
return std::ranges::any_of(info.images, [](const Shader::ImageResource& image) {
|
||||
return image.mip_fallback_mode == Shader::MipStorageFallbackMode::DynamicIndex;
|
||||
});
|
||||
}
|
||||
|
||||
bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) {
|
||||
compute_key.Deserialize(ar);
|
||||
|
||||
@@ -160,6 +172,11 @@ bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) {
|
||||
if (!LoadPipelineStage(meta_ar, 0)) {
|
||||
return false;
|
||||
}
|
||||
if (UsesSharpsToLayOut(*infos[0])) {
|
||||
infos.fill(nullptr);
|
||||
modules.fill(nullptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto [it, is_new] = compute_pipelines.try_emplace(compute_key);
|
||||
ASSERT(is_new);
|
||||
@@ -211,6 +228,7 @@ bool GraphicsPipeline::SerializationSupport::Deserialize(Serialization::Archive&
|
||||
}
|
||||
|
||||
bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) {
|
||||
preload_fetch_shader.reset();
|
||||
graphics_key.Deserialize(ar);
|
||||
|
||||
GraphicsPipeline::SerializationSupport sdata{};
|
||||
@@ -232,20 +250,44 @@ bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) {
|
||||
Serialization::Archive meta_ar{std::move(meta_blob)};
|
||||
|
||||
if (!LoadPipelineStage(meta_ar, stage_idx)) {
|
||||
infos.fill(nullptr);
|
||||
modules.fill(nullptr);
|
||||
preload_fetch_shader.reset();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (const auto* vs_info = infos[u32(Shader::LogicalStage::Vertex)];
|
||||
vs_info && vs_info->has_fetch_shader && !preload_fetch_shader) {
|
||||
// A pipeline that would draw without its vertex input is not preloaded: it is compiled
|
||||
// when the title first draws with it, from the fetch shader it uses then.
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Not preloading a pipeline: vertex shader {:#x} has a fetch shader, but the "
|
||||
"cache has no fetch data for it",
|
||||
vs_info->pgm_hash);
|
||||
infos.fill(nullptr);
|
||||
modules.fill(nullptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::ranges::any_of(infos, [](const Shader::Info* info) {
|
||||
return info != nullptr && UsesSharpsToLayOut(*info);
|
||||
})) {
|
||||
infos.fill(nullptr);
|
||||
modules.fill(nullptr);
|
||||
preload_fetch_shader.reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto [it, is_new] = graphics_pipelines.try_emplace(graphics_key);
|
||||
ASSERT(is_new);
|
||||
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, graphics_key, *pipeline_cache, infos,
|
||||
runtime_infos, fetch_shader, modules, sdata, true);
|
||||
runtime_infos, preload_fetch_shader, modules, sdata, true);
|
||||
|
||||
infos.fill(nullptr);
|
||||
modules.fill(nullptr);
|
||||
fetch_shader.reset();
|
||||
preload_fetch_shader.reset();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -255,9 +297,15 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage)
|
||||
Shader::StageSpecialization spec{};
|
||||
spec.info = &program->info;
|
||||
size_t perm_idx{};
|
||||
if (!LoadShaderMeta(ar, program->info, fetch_shader, spec, perm_idx)) {
|
||||
std::optional<Shader::Gcn::FetchShaderData> stage_fetch{};
|
||||
if (!LoadShaderMeta(ar, program->info, stage_fetch, spec, perm_idx)) {
|
||||
return false;
|
||||
}
|
||||
// Only the stage that has a fetch shader (the vertex stage) sets the pipeline's fetch data,
|
||||
// as at runtime. A later stage, a geometry shader above all, has none and must not clear it.
|
||||
if (stage_fetch) {
|
||||
preload_fetch_shader = std::move(stage_fetch);
|
||||
}
|
||||
|
||||
std::vector<u32> spv{};
|
||||
Storage::DataBase::Instance().Load(Storage::BlobType::ShaderBinary,
|
||||
@@ -277,6 +325,9 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage)
|
||||
module = CompileSPV(spv, instance.GetDevice());
|
||||
it_pgm.value() = std::move(program);
|
||||
} else {
|
||||
// The permutation joins the program already cached: its specialization must point at
|
||||
// that program's info, not at the one this load made, which is gone when it returns.
|
||||
spec.info = &it_pgm.value()->info;
|
||||
const auto& it = std::ranges::find(it_pgm.value()->modules, spec, &Program::Module::spec);
|
||||
if (it != it_pgm.value()->modules.end()) {
|
||||
// If the permutation is already preloaded, make sure it has the same permutation index
|
||||
@@ -301,7 +352,7 @@ void PipelineCache::WarmUp() {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef ENABLE_BACHATA_RUNTIME
|
||||
#if defined(ENABLE_BACHATA_RUNTIME) || defined(SHADPS4_ENABLE_FEX_GUEST_CPU)
|
||||
// Cached Shader::Info / pipeline keys can embed guest pointers. Reloading
|
||||
// them under FEX SIGSEGVs the host (Driveclub CUSA00093: read 0x6fc40c3ee0
|
||||
// immediately after swapchain + ImGui init). Compile on demand instead.
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
#include "common/logging/log.h"
|
||||
#include "common/path_util.h"
|
||||
#include "core/emulator_settings.h"
|
||||
#include "sdl_window.h"
|
||||
#ifdef ENABLE_OPENXR_HOST
|
||||
#include "core/vr/openxr_host.h"
|
||||
#endif
|
||||
#include "sdl_window.h"
|
||||
#include "video_core/renderer_vulkan/vk_platform.h"
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "core/devtools/layer.h"
|
||||
#include "core/emulator_settings.h"
|
||||
#include "core/libraries/system/systemservice.h"
|
||||
#include "core/vr/spectator_view.h"
|
||||
#ifdef ENABLE_OPENXR_HOST
|
||||
#include "core/vr/openxr_host.h"
|
||||
#endif
|
||||
@@ -28,6 +29,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
@@ -845,8 +847,9 @@ Frame* Presenter::PrepareFrame(const Libraries::VideoOut::BufferAttributeGroup&
|
||||
return frame;
|
||||
}
|
||||
|
||||
HmdFrames Presenter::PrepareHmdFrame(std::span<const AmdGpu::Image, 2> eye_textures, u32 frame_id,
|
||||
u32& eye_width, u32& eye_height) {
|
||||
HmdFrames Presenter::PrepareHmdFrame(std::span<const AmdGpu::Image, 2> eye_textures,
|
||||
const Core::Vr::Fov& fov, u32 frame_id,
|
||||
u32& eye_width, u32& eye_height) {
|
||||
// The guest hands the eyes over as plain textures; they were rendered as color targets, so
|
||||
// the cache already holds their contents.
|
||||
std::array<VideoCore::TextureCache::ImageDesc, 2> descs;
|
||||
@@ -898,10 +901,32 @@ HmdFrames Presenter::PrepareHmdFrame(std::span<const AmdGpu::Image, 2> eye_textu
|
||||
// pictures at their full size, where the window only has a look at them.
|
||||
Frame* frame = vr_exporter->Acquire(swapchain.GetSurfaceFormat().format);
|
||||
const bool exported = frame != nullptr;
|
||||
static const auto desktop_view = [] {
|
||||
const char* value = std::getenv("SHADPS4_VR_DESKTOP_VIEW");
|
||||
return Core::Vr::ParseDesktopView(value != nullptr ? value : "");
|
||||
}();
|
||||
const bool spectator = desktop_view != Core::Vr::DesktopView::Stereo && !exported;
|
||||
static const bool desktop_crop = [] {
|
||||
const char* value = std::getenv("SHADPS4_VR_DESKTOP_CROP");
|
||||
return value != nullptr && std::string_view{value} == "1";
|
||||
}();
|
||||
const bool crop = spectator && desktop_crop;
|
||||
const float desktop_aspect = Core::Vr::DesktopViewAspect(
|
||||
desktop_view, fov, static_cast<float>(eye_width) / eye_height);
|
||||
Frame* const local = exported ? nullptr : vr_exporter->AcquireLocal(eye_width * 2, eye_height);
|
||||
if (!exported) {
|
||||
expected_ratio = static_cast<float>(eye_width * 2) / static_cast<float>(eye_height);
|
||||
frame = GetRenderFrame();
|
||||
expected_ratio = crop ? std::nullopt : std::optional{desktop_aspect};
|
||||
static const double window_fps = [] {
|
||||
const char* value = std::getenv("SHADPS4_VR_WINDOW_FPS");
|
||||
const double rate = value ? std::atof(value) : 60.0;
|
||||
const double result = std::isfinite(rate) ? std::clamp(rate, 0.0, 1000.0) : 60.0;
|
||||
LOG_INFO(Render_Vulkan, "Desktop mirror cap: {} FPS (headset rate independent)",
|
||||
result);
|
||||
return result;
|
||||
}();
|
||||
if (mirror_limiter.Due(std::chrono::steady_clock::now(), local != nullptr, window_fps)) {
|
||||
frame = GetRenderFrame();
|
||||
}
|
||||
if (!frame && !local) {
|
||||
return {};
|
||||
}
|
||||
@@ -973,9 +998,42 @@ HmdFrames Presenter::PrepareHmdFrame(std::span<const AmdGpu::Image, 2> eye_textu
|
||||
auto hmd_settings = pp_settings;
|
||||
hmd_settings.sharpen = sharpen;
|
||||
if (frame != nullptr) {
|
||||
// The marker is for hosts that only see the picture; a VR host is told the frame's id.
|
||||
pp_pass.Render(cmdbuf, regions_for(*frame), *frame, hmd_settings,
|
||||
exported ? std::nullopt : std::optional<u32>{frame_id});
|
||||
const auto bounds = Core::Vr::SpectatorContentRect(frame->width, frame->height,
|
||||
desktop_aspect, crop);
|
||||
const vk::Rect2D content{{bounds.x, bounds.y}, {bounds.width, bounds.height}};
|
||||
const vk::Rect2D clip{{bounds.x, std::max(0, bounds.y)},
|
||||
{bounds.width, std::min(bounds.height, frame->height)}};
|
||||
if (spectator && desktop_view == Core::Vr::DesktopView::Combined) {
|
||||
const auto layout = Core::Vr::CombinedEyeRegions(content.extent.width, fov);
|
||||
const auto region_for = [&](u32 eye) {
|
||||
const auto& region = layout[eye];
|
||||
return HostPasses::PostProcessingPass::Region{
|
||||
.input = eye_views[eye],
|
||||
.area{
|
||||
.offset{.x = content.offset.x + static_cast<s32>(region.x),
|
||||
.y = content.offset.y},
|
||||
.extent{.width = region.width, .height = content.extent.height},
|
||||
},
|
||||
.clip = vk::Rect2D{
|
||||
.offset{.x = content.offset.x + static_cast<s32>(region.clip_x),
|
||||
.y = clip.offset.y},
|
||||
.extent{.width = region.clip_width, .height = clip.extent.height},
|
||||
},
|
||||
};
|
||||
};
|
||||
const std::array regions{region_for(0), region_for(1)};
|
||||
const auto count = layout[1].clip_width == 0 ? 1u : 2u;
|
||||
pp_pass.Render(cmdbuf, std::span{regions}.first(count), *frame, hmd_settings);
|
||||
} else if (spectator) {
|
||||
const std::array regions{
|
||||
HostPasses::PostProcessingPass::Region{
|
||||
.input = eye_views[0], .area = content, .clip = clip},
|
||||
};
|
||||
pp_pass.Render(cmdbuf, regions, *frame, hmd_settings);
|
||||
} else {
|
||||
pp_pass.Render(cmdbuf, regions_for(*frame), *frame, hmd_settings,
|
||||
exported ? std::nullopt : std::optional<u32>{frame_id});
|
||||
}
|
||||
if (exported) {
|
||||
vr_exporter->Finalize(frame, cmdbuf);
|
||||
}
|
||||
@@ -1199,7 +1257,7 @@ void Presenter::DumpGpuImages() {
|
||||
}
|
||||
// (Several addresses may be given, apart by commas.)
|
||||
std::vector<VAddr> wanted_list;
|
||||
for (const char* at = wanted_text; *at != ' | ||||