mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-22 04:09:11 +02:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e409d280ce | ||
|
|
932b9ea7d9 | ||
|
|
7dc4f55642 | ||
|
|
adccf4e484 | ||
|
|
c4be3de14a | ||
|
|
8370850bb7 | ||
|
|
876f8b8c32 | ||
|
|
1854e00934 | ||
|
|
c614fba032 | ||
|
|
0221738952 | ||
|
|
046632cf99 | ||
|
|
753a3f8106 | ||
|
|
569df68729 | ||
|
|
043964a5dc | ||
|
|
ea248b1ecf | ||
|
|
35033454b0 | ||
|
|
df025d2eb7 | ||
|
|
dea1d5fd59 | ||
|
|
9ae5667cd7 | ||
|
|
7296520064 | ||
|
|
e094a4c89d | ||
|
|
de0ddf7864 | ||
|
|
e553db2001 | ||
|
|
44ec71f826 | ||
|
|
42b5138b11 | ||
|
|
2eaba9048b | ||
|
|
a2ffa5c811 | ||
|
|
2be319a979 | ||
|
|
6cf641d472 | ||
|
|
3788026d10 | ||
|
|
f3e68fc3b7 | ||
|
|
0b0d8067d8 | ||
|
|
87ac6ee572 | ||
|
|
fcc8b5382a | ||
|
|
9826535102 | ||
|
|
a30f05fc5b | ||
|
|
1ff05e4e6f | ||
|
|
123968be89 | ||
|
|
393297a439 | ||
|
|
7ec8304d8f | ||
|
|
f9289d9947 | ||
|
|
7294a46e8a | ||
|
|
4b67176970 | ||
|
|
228121157b | ||
|
|
a06c7de93e | ||
|
|
8106203c8e | ||
|
|
0603734ff6 | ||
|
|
0b5578d80b | ||
|
|
bafbe4268c | ||
|
|
3ef81c3d93 | ||
|
|
360ab6af58 | ||
|
|
4cc0e41ef3 | ||
|
|
ddc985218b | ||
|
|
33dd7769f0 | ||
|
|
88bb4c940c | ||
|
|
68f8ec82d2 | ||
|
|
509822974b | ||
|
|
a441fa7110 | ||
|
|
d54d20de64 | ||
|
|
f9fede1649 | ||
|
|
9a1ccbb5ce | ||
|
|
ecb80b3ee1 | ||
|
|
6362283ade | ||
|
|
fbf97df708 | ||
|
|
25cfd94c56 | ||
|
|
3c20ee3c55 | ||
|
|
5e24bee4ab | ||
|
|
a014751cd4 | ||
|
|
7e057232e4 | ||
|
|
f5080e8a99 | ||
|
|
3d89f128ce | ||
|
|
b187efbedf | ||
|
|
dd23f06a8b |
No files matched your search
@@ -0,0 +1,20 @@
|
|||||||
|
**/.git
|
||||||
|
/build-frame-arm64/
|
||||||
|
/build/
|
||||||
|
/build-test/
|
||||||
|
/build_*/
|
||||||
|
/frame-ui-local/
|
||||||
|
/rbx_bin/
|
||||||
|
/runtime/
|
||||||
|
/data/
|
||||||
|
/dist/
|
||||||
|
/out/
|
||||||
|
**/__pycache__/
|
||||||
|
*.log
|
||||||
|
*.bundle
|
||||||
|
*.apk
|
||||||
|
*.xapk
|
||||||
|
*.aab
|
||||||
|
*.obb
|
||||||
|
libroblox.so
|
||||||
|
cookies*.txt
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
# snapshot. Local Mocktail sources remain subject to git diff --check.
|
# snapshot. Local Mocktail sources remain subject to git diff --check.
|
||||||
third_party/mcpelauncher-linker/core/** -whitespace
|
third_party/mcpelauncher-linker/core/** -whitespace
|
||||||
packaging/freebsd/*.patch -whitespace
|
packaging/freebsd/*.patch -whitespace
|
||||||
|
|
||||||
|
# Preserve original third-party shader/header whitespace.
|
||||||
|
src/vr/smaa/** -whitespace
|
||||||
|
tools/frame_video_settings/openvr.h -whitespace
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report a reproducible problem in Nightcap
|
description: Report a reproducible problem on Steam Frame
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
@@ -13,7 +13,6 @@ body:
|
|||||||
|
|
||||||
Use any language you are comfortable with.
|
Use any language you are comfortable with.
|
||||||
|
|
||||||
Please write issues and pull requests yourself.
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
@@ -28,8 +27,8 @@ body:
|
|||||||
id: system
|
id: system
|
||||||
attributes:
|
attributes:
|
||||||
label: System information
|
label: System information
|
||||||
description: Include your distribution, desktop session, GPU, and graphics driver when relevant.
|
description: Include the SteamOS, SteamVR and graphics driver versions.
|
||||||
placeholder: "For example: Arch Linux, KDE Plasma 6 on Wayland, AMD RX 6600, Mesa 26.1"
|
placeholder: "Steam Frame, SteamOS version, SteamVR build, Turnip version"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Installation method
|
label: Installation method
|
||||||
description: Tell us how Nightcap was installed or built.
|
description: Tell us how Nightcap was installed or built.
|
||||||
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
|
placeholder: "Source build using the Steam Frame container recipe"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -76,10 +75,8 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Latest log:
|
Latest log:
|
||||||
|
|
||||||
- Flatpak: `~/.var/app/io.github.CoderDayton.nightcap/.local/state/mocktail/logs/latest.log`
|
`~/.local/share/nightcap-vr-arm64/last-launch.log`
|
||||||
- Other installs: `~/.local/state/mocktail/logs/latest.log`
|
|
||||||
|
|
||||||
Older logs are in the `sessions` directory next to `latest.log`.
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ body:
|
|||||||
|
|
||||||
Use any language you are comfortable with.
|
Use any language you are comfortable with.
|
||||||
|
|
||||||
Please write issues and pull requests yourself.
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem
|
id: problem
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
|
## Change
|
||||||
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
|
|
||||||
|
|
||||||
Closes #
|
Describe the behavior changed on Steam Frame and link any relevant issue.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
Describe the build, tests or device checks run for this change.
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
name: Publish AUR packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: Release tag to publish, such as v0.3.4
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
AUR_SSH_PRIVATE_KEY:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: aur-publish
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish nightcap and nightcap-bin
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
environment: release
|
|
||||||
steps:
|
|
||||||
- name: Check out the release
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.tag }}
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Render the recipes
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TAG: ${{ inputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]
|
|
||||||
version="${TAG#v}"
|
|
||||||
archive="nightcap-${version}-1-x86_64.pkg.tar.zst"
|
|
||||||
# nightcap-bin installs the pacman package from the release page, so
|
|
||||||
# its checksum is the one published beside that asset.
|
|
||||||
checksum="$(curl -fsSL \
|
|
||||||
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${archive}.sha256" |
|
|
||||||
awk '{print $1}')"
|
|
||||||
[[ "$checksum" =~ ^[0-9a-f]{64}$ ]]
|
|
||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${version}/" \
|
|
||||||
packaging/aur/nightcap/PKGBUILD packaging/aur/nightcap-bin/PKGBUILD
|
|
||||||
sed -i "s/^sha256sums=('SKIP')$/sha256sums=('${checksum}')/" \
|
|
||||||
packaging/aur/nightcap-bin/PKGBUILD
|
|
||||||
grep -Fqx "sha256sums=('${checksum}')" packaging/aur/nightcap-bin/PKGBUILD
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$GITHUB_WORKSPACE/packaging/aur:/aur" \
|
|
||||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
|
||||||
useradd --create-home builder
|
|
||||||
for package in nightcap nightcap-bin; do
|
|
||||||
install -d -o builder -g builder "/tmp/${package}"
|
|
||||||
install -o builder -g builder -m 0644 \
|
|
||||||
"/aur/${package}/PKGBUILD" "/tmp/${package}/PKGBUILD"
|
|
||||||
runuser -u builder -- bash -c \
|
|
||||||
"cd /tmp/${package} && makepkg --printsrcinfo" \
|
|
||||||
> "/aur/${package}/.SRCINFO"
|
|
||||||
done
|
|
||||||
'
|
|
||||||
|
|
||||||
for package in nightcap nightcap-bin; do
|
|
||||||
grep -Fqx "pkgver=${version}" "packaging/aur/${package}/PKGBUILD"
|
|
||||||
grep -Fqx $'\tpkgver = '"${version}" "packaging/aur/${package}/.SRCINFO"
|
|
||||||
done
|
|
||||||
echo "VERSION=${version}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Push to the AUR
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
if [[ -z "${AUR_SSH_PRIVATE_KEY}" ]]; then
|
|
||||||
echo "::notice::AUR_SSH_PRIVATE_KEY is not set; nothing was pushed to the AUR"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$ssh_dir"' EXIT
|
|
||||||
install -m 0600 /dev/null "$ssh_dir/key"
|
|
||||||
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$ssh_dir/key"
|
|
||||||
# The AUR lists this key's fingerprint on its front page:
|
|
||||||
# SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4
|
|
||||||
printf '%s\n' \
|
|
||||||
'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \
|
|
||||||
> "$ssh_dir/known_hosts"
|
|
||||||
export GIT_SSH_COMMAND="ssh -i $ssh_dir/key -o IdentitiesOnly=yes -o UserKnownHostsFile=$ssh_dir/known_hosts -o StrictHostKeyChecking=yes"
|
|
||||||
|
|
||||||
for package in nightcap nightcap-bin; do
|
|
||||||
checkout="$ssh_dir/$package"
|
|
||||||
git clone "ssh://aur@aur.archlinux.org/${package}.git" "$checkout"
|
|
||||||
cp "packaging/aur/${package}/PKGBUILD" "packaging/aur/${package}/.SRCINFO" \
|
|
||||||
"$checkout/"
|
|
||||||
git -C "$checkout" add PKGBUILD .SRCINFO
|
|
||||||
if git -C "$checkout" diff --cached --quiet; then
|
|
||||||
echo "::notice::${package} is already at ${VERSION}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
git -C "$checkout" \
|
|
||||||
-c user.name='Nightcap release' \
|
|
||||||
-c user.email='CoderDayton@users.noreply.github.com' \
|
|
||||||
commit --quiet -m "Update to ${VERSION}"
|
|
||||||
# The AUR only accepts the master branch.
|
|
||||||
git -C "$checkout" push origin HEAD:master
|
|
||||||
done
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
name: Check AUR packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: aur-packages
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
metadata:
|
|
||||||
name: Refresh AUR metadata
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Prepare AUR metadata
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
package_dir="$GITHUB_WORKSPACE/packaging/aur/nightcap-git"
|
|
||||||
version="$(git describe --tags --abbrev=0 --match='v[0-9]*')"
|
|
||||||
version="${version#v}"
|
|
||||||
[[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]
|
|
||||||
pkgver="${version}.r$(git rev-list --count HEAD).g$(git rev-parse --short=7 HEAD)"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${pkgver}/" "$package_dir/PKGBUILD"
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$package_dir:/pkg" \
|
|
||||||
archlinux:base-devel bash -Eeuo pipefail -c '
|
|
||||||
useradd --create-home builder
|
|
||||||
install -d -o builder -g builder /tmp/nightcap-git
|
|
||||||
install -o builder -g builder -m 0644 \
|
|
||||||
/pkg/PKGBUILD /tmp/nightcap-git/PKGBUILD
|
|
||||||
runuser -u builder -- bash -c \
|
|
||||||
"cd /tmp/nightcap-git && makepkg --printsrcinfo" \
|
|
||||||
> /pkg/.SRCINFO
|
|
||||||
'
|
|
||||||
|
|
||||||
grep -Fqx "pkgver=${pkgver}" "$package_dir/PKGBUILD"
|
|
||||||
grep -Fqx $'\tpkgver = '"${pkgver}" "$package_dir/.SRCINFO"
|
|
||||||
|
|
||||||
- name: Upload AUR metadata
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-AUR
|
|
||||||
path: packaging/aur/nightcap-git/.SRCINFO
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'CMakeLists.txt'
|
|
||||||
- 'Makefile'
|
|
||||||
- 'README.md'
|
|
||||||
- '.gitmodules'
|
|
||||||
- 'cmake/**'
|
|
||||||
- 'config/**'
|
|
||||||
- 'include/**'
|
|
||||||
- 'packaging/**'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'src/**'
|
|
||||||
- 'stubs/**'
|
|
||||||
- 'tests/**'
|
|
||||||
- 'third_party/**'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'CMakeLists.txt'
|
|
||||||
- 'Makefile'
|
|
||||||
- 'README.md'
|
|
||||||
- '.gitmodules'
|
|
||||||
- 'cmake/**'
|
|
||||||
- 'config/**'
|
|
||||||
- 'include/**'
|
|
||||||
- 'packaging/**'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'src/**'
|
|
||||||
- 'stubs/**'
|
|
||||||
- 'tests/**'
|
|
||||||
- 'third_party/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
contracts:
|
|
||||||
name: Repository contracts
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Validate shell and JSON files
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
find scripts tests -type f -name '*.sh' -print0 | xargs -0 -n1 bash -n
|
|
||||||
find config packaging/flatpak -type f -name '*.json' -print0 \
|
|
||||||
| xargs -0 -n1 python3 -m json.tool >/dev/null
|
|
||||||
|
|
||||||
- name: Run script unit tests
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --disable-pip-version-check requests
|
|
||||||
python3 -m unittest discover -s tests -p '*_test.py' -v
|
|
||||||
|
|
||||||
- name: Verify build and packaging contracts
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
bash tests/managed_payload_build_contract_test.sh .
|
|
||||||
bash tests/flatpak_packaging_test.sh .
|
|
||||||
bash tests/native_packaging_test.sh .
|
|
||||||
bash tests/anylinux_dependencies_test.sh .
|
|
||||||
|
|
||||||
native-build:
|
|
||||||
name: Native build (${{ matrix.arch }})
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
timeout-minutes: 60
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: x86_64
|
|
||||||
runner: ubuntu-26.04
|
|
||||||
- arch: aarch64
|
|
||||||
runner: ubuntu-26.04-arm
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install build dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes --no-install-recommends \
|
|
||||||
build-essential binutils cmake git ninja-build pkg-config lld \
|
|
||||||
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
|
|
||||||
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
|
||||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
|
||||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
|
||||||
libplacebo-dev libpng-dev zlib1g-dev \
|
|
||||||
xvfb xauth libgl1-mesa-dri jq patchelf
|
|
||||||
|
|
||||||
- name: Build and check native executables
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
test "$(uname -m)" = "${{ matrix.arch }}"
|
|
||||||
cmake -S . -B build-ci -G Ninja \
|
|
||||||
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
|
|
||||||
cmake --build build-ci --parallel 4
|
|
||||||
./build-ci/mocktail --help
|
|
||||||
./build-ci/mocktail_updater status
|
|
||||||
|
|
||||||
- name: Run C++ and integration tests
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
SDL_AUDIODRIVER: dummy
|
|
||||||
SDL_VIDEODRIVER: x11
|
|
||||||
LIBGL_ALWAYS_SOFTWARE: '1'
|
|
||||||
run: |
|
|
||||||
xvfb-run -a ctest --test-dir build-ci --output-on-failure \
|
|
||||||
--parallel 4 --timeout 120
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
|
||||||
name: Flatpak
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/flatpak.yml'
|
|
||||||
- '.gitmodules'
|
|
||||||
- 'CMakeLists.txt'
|
|
||||||
- 'LICENSE'
|
|
||||||
- 'assets/screenshots/**'
|
|
||||||
- 'cmake/**'
|
|
||||||
- 'config/**'
|
|
||||||
- 'include/**'
|
|
||||||
- 'packaging/flatpak/**'
|
|
||||||
- 'packaging/icons/**'
|
|
||||||
- 'packaging/io.github.CoderDayton.nightcap.desktop'
|
|
||||||
- 'packaging/io.github.CoderDayton.nightcap.metainfo.xml'
|
|
||||||
- 'packaging/io.github.CoderDayton.nightcap.svg'
|
|
||||||
- 'scripts/assemble_flatpak_pages.sh'
|
|
||||||
- 'site/**'
|
|
||||||
- 'src/**'
|
|
||||||
- 'stubs/**'
|
|
||||||
- 'third_party/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: flatpak-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flatpak:
|
|
||||||
name: Flatpak build
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 120
|
|
||||||
# The signing key lives in the release environment, which only main and
|
|
||||||
# v* tags may use. Builds that do not publish run without it.
|
|
||||||
environment: ${{ (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) && 'release' || '' }}
|
|
||||||
env:
|
|
||||||
# Publish on release tags and on manual runs from main. Pull requests
|
|
||||||
# only verify that the manifest still builds.
|
|
||||||
MOCKTAIL_PUBLISH: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install Flatpak builder
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes flatpak flatpak-builder gnupg
|
|
||||||
flatpak remote-add --user --if-not-exists flathub \
|
|
||||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
|
||||||
|
|
||||||
- name: Build Flatpak repository and bundle
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
|
||||||
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
# The manifest builds the local checkout from refs/heads/main.
|
|
||||||
git update-ref refs/heads/main "$GITHUB_SHA"
|
|
||||||
builder_signing=()
|
|
||||||
bundle_keys=()
|
|
||||||
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
|
|
||||||
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && \
|
|
||||||
-n "$FLATPAK_GPG_KEY_ID" ]]
|
|
||||||
install -d -m 0700 "$RUNNER_TEMP/nightcap-gnupg"
|
|
||||||
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
|
|
||||||
gpg --batch --homedir "$RUNNER_TEMP/nightcap-gnupg" --import
|
|
||||||
gpg --batch --homedir "$RUNNER_TEMP/nightcap-gnupg" \
|
|
||||||
--export "$FLATPAK_GPG_KEY_ID" >nightcap-flatpak.gpg
|
|
||||||
builder_signing=(
|
|
||||||
"--gpg-sign=$FLATPAK_GPG_KEY_ID"
|
|
||||||
"--gpg-homedir=$RUNNER_TEMP/nightcap-gnupg"
|
|
||||||
)
|
|
||||||
bundle_keys=(--gpg-keys=nightcap-flatpak.gpg)
|
|
||||||
fi
|
|
||||||
flatpak-builder \
|
|
||||||
--arch=x86_64 \
|
|
||||||
--jobs=4 \
|
|
||||||
--user \
|
|
||||||
--install-deps-from=flathub \
|
|
||||||
--disable-rofiles-fuse \
|
|
||||||
--force-clean \
|
|
||||||
--default-branch=stable \
|
|
||||||
--repo=repo \
|
|
||||||
"${builder_signing[@]}" \
|
|
||||||
build-flatpak-ci \
|
|
||||||
packaging/flatpak/io.github.CoderDayton.nightcap.json
|
|
||||||
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
|
|
||||||
flatpak build-update-repo \
|
|
||||||
--generate-static-deltas \
|
|
||||||
--static-delta-jobs=2 \
|
|
||||||
--prune \
|
|
||||||
--gpg-sign="$FLATPAK_GPG_KEY_ID" \
|
|
||||||
--gpg-homedir="$RUNNER_TEMP/nightcap-gnupg" \
|
|
||||||
repo
|
|
||||||
fi
|
|
||||||
flatpak build-bundle \
|
|
||||||
--arch=x86_64 \
|
|
||||||
"${bundle_keys[@]}" \
|
|
||||||
repo \
|
|
||||||
Nightcap-x86_64.flatpak \
|
|
||||||
io.github.CoderDayton.nightcap \
|
|
||||||
stable
|
|
||||||
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
|
|
||||||
./scripts/assemble_flatpak_pages.sh \
|
|
||||||
repo Nightcap-x86_64.flatpak nightcap-flatpak.gpg public
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Flatpak
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-x86_64-flatpak
|
|
||||||
path: Nightcap-x86_64.flatpak
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
- name: Configure GitHub Pages
|
|
||||||
if: env.MOCKTAIL_PUBLISH == 'true'
|
|
||||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
|
||||||
|
|
||||||
- name: Upload GitHub Pages repository
|
|
||||||
if: env.MOCKTAIL_PUBLISH == 'true'
|
|
||||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
|
||||||
with:
|
|
||||||
path: public
|
|
||||||
|
|
||||||
deploy-pages:
|
|
||||||
name: Publish Flatpak repository
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
|
||||||
needs: flatpak
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- name: Deploy GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Attach bundle to GitHub release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
needs: flatpak
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
|
|
||||||
- name: Download Flatpak
|
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-x86_64-flatpak
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Upload to release
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
tag="${GITHUB_REF_NAME}"
|
|
||||||
(cd release && sha256sum Nightcap-x86_64.flatpak > Nightcap-x86_64.flatpak.sha256)
|
|
||||||
./scripts/release_notes.sh "$tag" > release/notes.md
|
|
||||||
# The Release workflow creates the same release; whichever job
|
|
||||||
# arrives second sees "already exists" and continues.
|
|
||||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "Nightcap $tag" --notes-file release/notes.md ||
|
|
||||||
gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null
|
|
||||||
fi
|
|
||||||
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "Nightcap $tag" --notes-file release/notes.md
|
|
||||||
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
|
|
||||||
release/Nightcap-x86_64.flatpak \
|
|
||||||
release/Nightcap-x86_64.flatpak.sha256
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
name: Nightly AppImage
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# 05:00 UTC daily. A run whose commit already carries the continuous tag
|
|
||||||
# exits without rebuilding, so a quiet day costs one probe job.
|
|
||||||
- cron: '0 5 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: nightly-appimage
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
probe:
|
|
||||||
name: Check for new commits
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 5
|
|
||||||
outputs:
|
|
||||||
build: ${{ steps.decide.outputs.build }}
|
|
||||||
steps:
|
|
||||||
- name: Compare HEAD against the published continuous tag
|
|
||||||
id: decide
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
published="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/continuous" \
|
|
||||||
--jq .object.sha 2>/dev/null || true)"
|
|
||||||
if [[ "$GITHUB_EVENT_NAME" == "schedule" && "$published" == "$GITHUB_SHA" ]]; then
|
|
||||||
echo 'build=false' >>"$GITHUB_OUTPUT"
|
|
||||||
echo "continuous already points at $GITHUB_SHA; nothing to build"
|
|
||||||
else
|
|
||||||
echo 'build=true' >>"$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
appimage:
|
|
||||||
name: AppImage (AnyLinux)
|
|
||||||
needs: probe
|
|
||||||
if: needs.probe.outputs.build == 'true'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 90
|
|
||||||
env:
|
|
||||||
UPINFO: gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|continuous|Nightcap-nightly-x86_64.AppImage.zsync
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
|
||||||
--workdir /workspace \
|
|
||||||
--env GH_TOKEN \
|
|
||||||
--env UPINFO \
|
|
||||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
|
||||||
pacman -Syu --noconfirm --needed \
|
|
||||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
|
||||||
nlohmann-json libyaml libelf minizip minizip-ng capstone gtk4 \
|
|
||||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
|
|
||||||
vulkan-headers zlib libplacebo bubblewrap xdg-dbus-proxy \
|
|
||||||
patchelf jq unzip file ca-certificates dbus zsync
|
|
||||||
|
|
||||||
# Reduced ABI-compatible packages belong only in this builder.
|
|
||||||
# The native updater does not require a JRE or Android SDK.
|
|
||||||
./scripts/install_anylinux_dependencies.sh
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/quick-sharun \
|
|
||||||
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/quick-sharun.sh
|
|
||||||
echo "fa66d0189e1e87df781c049dfb23cbb1e753786056f693a4483d6d3af61e7ee1 /tmp/quick-sharun" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/quick-sharun
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/sharun \
|
|
||||||
https://github.com/pkgforge-dev/Anylinux-sharun/releases/download/2.2.6/sharun-x86_64
|
|
||||||
echo "a4d51752f9861b4870ca529fea04087310b28f8943cc55b99a95b777d4db92ec /tmp/sharun" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/sharun
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/anylinux.c \
|
|
||||||
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/lib/anylinux.c
|
|
||||||
echo "f50650ad96d177559bdd0737df509fa20bd4af08887631c738fc1f31bfac185f /tmp/anylinux.c" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/appimagetool \
|
|
||||||
https://github.com/pkgforge-dev/appimagetool/releases/download/0.3.3/appimagetool-x86_64-linux
|
|
||||||
echo "0d01a4e2628efc897be9663f0faeed6a171e1851270b4e1ce4c22434a36ecebd /tmp/appimagetool" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/appimagetool
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/uruntime \
|
|
||||||
https://github.com/VHSgunzo/uruntime/releases/download/v0.5.9/uruntime-appimage-dwarfs-lite-x86_64
|
|
||||||
echo "cef962c299f2fa19b2b3cdf2fa1565ee8541796cc89b9a97a591f94041e8b083 /tmp/uruntime" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/uruntime
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/mkdwarfs \
|
|
||||||
https://github.com/mhx/dwarfs/releases/download/v0.15.6/dwarfs-universal-0.15.6-Linux-x86_64
|
|
||||||
echo "50891c38ba359db8271819a6cbf6aaa8068681523f0c4f2b8242007a45edaa28 /tmp/mkdwarfs" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/mkdwarfs
|
|
||||||
|
|
||||||
git config --global --add safe.directory /workspace
|
|
||||||
git config --global --add safe.directory /workspace/third_party/Vulkan-Headers
|
|
||||||
git config --global --add safe.directory /workspace/third_party/libjnivm
|
|
||||||
# Install into the disposable container /usr before deployment.
|
|
||||||
export MOCKTAIL_ANYLINUX_SYSTEM_INSTALL=1
|
|
||||||
export MOCKTAIL_ANYLINUX_SHARUN=/tmp/sharun
|
|
||||||
export MOCKTAIL_ANYLINUX_C_SOURCE=/tmp/anylinux.c
|
|
||||||
export RUNTIME=/tmp/uruntime
|
|
||||||
export DWARFS_CMD=/tmp/mkdwarfs
|
|
||||||
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
|
||||||
APPIMAGE=dist/Nightcap-nightly-x86_64.AppImage \
|
|
||||||
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
|
|
||||||
ANYLINUX_PACKAGER=/tmp/quick-sharun \
|
|
||||||
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
|
|
||||||
test -x dist/Nightcap-nightly-x86_64.AppImage
|
|
||||||
test -s dist/Nightcap-nightly-x86_64.AppImage.zsync
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Smoke-test AppImage without FUSE
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
smoke_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf -- "$smoke_dir"' EXIT
|
|
||||||
export XDG_DATA_HOME="$smoke_dir/data"
|
|
||||||
export XDG_CONFIG_HOME="$smoke_dir/config"
|
|
||||||
export XDG_CACHE_HOME="$smoke_dir/cache"
|
|
||||||
export XDG_STATE_HOME="$smoke_dir/state"
|
|
||||||
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
|
|
||||||
test "$(dist/Nightcap-nightly-x86_64.AppImage --appimage-updateinformation)" = "$UPINFO"
|
|
||||||
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run --help
|
|
||||||
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-AppImage
|
|
||||||
path: |
|
|
||||||
dist/Nightcap-nightly-x86_64.AppImage
|
|
||||||
dist/Nightcap-nightly-x86_64.AppImage.zsync
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish continuous release
|
|
||||||
needs: [probe, appimage]
|
|
||||||
# A manual run from another branch still builds and smoke-tests, but the
|
|
||||||
# continuous tag and release only ever follow main.
|
|
||||||
if: needs.probe.outputs.build == 'true' && github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 30
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Download AppImage
|
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-AppImage
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Checksum the nightly build
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
# The .zsync file names the AppImage it updates, so the build already
|
|
||||||
# carries the published name.
|
|
||||||
test -s release/Nightcap-nightly-x86_64.AppImage
|
|
||||||
test -s release/Nightcap-nightly-x86_64.AppImage.zsync
|
|
||||||
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
|
|
||||||
> Nightcap-nightly-x86_64.AppImage.sha256)
|
|
||||||
find release -maxdepth 1 -type f -print
|
|
||||||
|
|
||||||
- name: Update continuous release
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
git config user.name 'github-actions[bot]'
|
|
||||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
||||||
git tag --force continuous "$GITHUB_SHA"
|
|
||||||
git push origin refs/tags/continuous --force
|
|
||||||
notes="Automated build of main at ${GITHUB_SHA:0:8}. Untested and unreleased; use a tagged release for normal play."
|
|
||||||
if ! gh release view continuous >/dev/null 2>&1; then
|
|
||||||
gh release create continuous \
|
|
||||||
--prerelease \
|
|
||||||
--title "Nightcap continuous" \
|
|
||||||
--notes "$notes"
|
|
||||||
fi
|
|
||||||
gh release edit continuous --title "Nightcap continuous" --notes "$notes"
|
|
||||||
gh release upload continuous release/* --clobber
|
|
||||||
# Drop assets left by earlier runs under other names.
|
|
||||||
while IFS= read -r asset; do
|
|
||||||
case "$asset" in
|
|
||||||
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.zsync|Nightcap-nightly-x86_64.AppImage.sha256)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
gh release delete-asset continuous "$asset" --yes
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < <(gh release view continuous --json assets --jq '.assets[].name')
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
appimage:
|
|
||||||
name: AppImage (AnyLinux)
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Check release notes
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
shell: bash
|
|
||||||
run: ./scripts/release_notes.sh "$GITHUB_REF_NAME" >/dev/null
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
|
||||||
--workdir /workspace \
|
|
||||||
--env GH_TOKEN \
|
|
||||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
|
||||||
pacman -Syu --noconfirm --needed \
|
|
||||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
|
||||||
nlohmann-json libyaml libelf minizip minizip-ng capstone gtk4 \
|
|
||||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
|
|
||||||
vulkan-headers zlib libplacebo bubblewrap xdg-dbus-proxy \
|
|
||||||
patchelf jq unzip file ca-certificates dbus zsync
|
|
||||||
|
|
||||||
./scripts/install_anylinux_dependencies.sh
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/quick-sharun \
|
|
||||||
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/quick-sharun.sh
|
|
||||||
echo "fa66d0189e1e87df781c049dfb23cbb1e753786056f693a4483d6d3af61e7ee1 /tmp/quick-sharun" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/quick-sharun
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/sharun \
|
|
||||||
https://github.com/pkgforge-dev/Anylinux-sharun/releases/download/2.2.6/sharun-x86_64
|
|
||||||
echo "a4d51752f9861b4870ca529fea04087310b28f8943cc55b99a95b777d4db92ec /tmp/sharun" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/sharun
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/anylinux.c \
|
|
||||||
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/lib/anylinux.c
|
|
||||||
echo "f50650ad96d177559bdd0737df509fa20bd4af08887631c738fc1f31bfac185f /tmp/anylinux.c" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/appimagetool \
|
|
||||||
https://github.com/pkgforge-dev/appimagetool/releases/download/0.3.3/appimagetool-x86_64-linux
|
|
||||||
echo "0d01a4e2628efc897be9663f0faeed6a171e1851270b4e1ce4c22434a36ecebd /tmp/appimagetool" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/appimagetool
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/uruntime \
|
|
||||||
https://github.com/VHSgunzo/uruntime/releases/download/v0.5.9/uruntime-appimage-dwarfs-lite-x86_64
|
|
||||||
echo "cef962c299f2fa19b2b3cdf2fa1565ee8541796cc89b9a97a591f94041e8b083 /tmp/uruntime" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/uruntime
|
|
||||||
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
--output /tmp/mkdwarfs \
|
|
||||||
https://github.com/mhx/dwarfs/releases/download/v0.15.6/dwarfs-universal-0.15.6-Linux-x86_64
|
|
||||||
echo "50891c38ba359db8271819a6cbf6aaa8068681523f0c4f2b8242007a45edaa28 /tmp/mkdwarfs" | \
|
|
||||||
sha256sum --check --strict
|
|
||||||
chmod 0755 /tmp/mkdwarfs
|
|
||||||
|
|
||||||
git config --global --add safe.directory /workspace
|
|
||||||
git config --global --add safe.directory /workspace/third_party/Vulkan-Headers
|
|
||||||
git config --global --add safe.directory /workspace/third_party/libjnivm
|
|
||||||
export MOCKTAIL_ANYLINUX_SYSTEM_INSTALL=1
|
|
||||||
export MOCKTAIL_ANYLINUX_SHARUN=/tmp/sharun
|
|
||||||
export MOCKTAIL_ANYLINUX_C_SOURCE=/tmp/anylinux.c
|
|
||||||
export RUNTIME=/tmp/uruntime
|
|
||||||
export DWARFS_CMD=/tmp/mkdwarfs
|
|
||||||
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
|
||||||
APPIMAGE=dist/Mocktail-x86_64.AppImage \
|
|
||||||
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
|
|
||||||
ANYLINUX_PACKAGER=/tmp/quick-sharun \
|
|
||||||
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
|
|
||||||
test -x dist/Mocktail-x86_64.AppImage
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Smoke-test AppImage without FUSE
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
smoke_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf -- "$smoke_dir"' EXIT
|
|
||||||
export XDG_DATA_HOME="$smoke_dir/data"
|
|
||||||
export XDG_CONFIG_HOME="$smoke_dir/config"
|
|
||||||
export XDG_CACHE_HOME="$smoke_dir/cache"
|
|
||||||
export XDG_STATE_HOME="$smoke_dir/state"
|
|
||||||
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
|
|
||||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
|
|
||||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Mocktail-AppImage
|
|
||||||
path: dist/Mocktail-x86_64.AppImage
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
deb:
|
|
||||||
name: DEB (Ubuntu 26.04)
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Build DEB
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
|
||||||
--workdir /workspace \
|
|
||||||
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
|
|
||||||
ubuntu:26.04 bash -Eeuo pipefail -c '
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update
|
|
||||||
apt-get install --yes --no-install-recommends \
|
|
||||||
build-essential cmake git ninja-build pkg-config lld fakeroot \
|
|
||||||
dpkg-dev file libsdl3-dev libsdl3-ttf-dev \
|
|
||||||
libcurl4-openssl-dev libssl-dev nlohmann-json3-dev \
|
|
||||||
libyaml-dev libelf-dev libminizip-dev libcapstone-dev \
|
|
||||||
libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
|
||||||
libutf8proc-dev libfontconfig1-dev libegl-dev \
|
|
||||||
libvulkan-dev libplacebo-dev zlib1g-dev libpng-dev
|
|
||||||
cmake -S . -B build-deb -G Ninja \
|
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
|
||||||
-DMOCKTAIL_PACKAGE_NAME=nightcap \
|
|
||||||
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
|
|
||||||
-DBUILD_TESTING=OFF
|
|
||||||
cmake --build build-deb --parallel 4
|
|
||||||
cpack --config build-deb/CPackConfig.cmake \
|
|
||||||
-G DEB -B dist/deb
|
|
||||||
dpkg-deb --info dist/deb/*.deb >/dev/null
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Upload DEB
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-DEB
|
|
||||||
path: dist/deb/*.deb
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
rpm:
|
|
||||||
name: RPM (Fedora 44)
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Build RPM
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
|
||||||
--workdir /workspace \
|
|
||||||
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
|
|
||||||
fedora:44 bash -Eeuo pipefail -c '
|
|
||||||
dnf install --assumeyes \
|
|
||||||
gcc-c++ cmake git ninja-build pkgconf-pkg-config lld rpm-build \
|
|
||||||
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
|
|
||||||
nlohmann-json-devel libyaml-devel elfutils-libelf-devel \
|
|
||||||
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
|
|
||||||
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
|
|
||||||
libglvnd-devel vulkan-headers vulkan-loader-devel \
|
|
||||||
libplacebo-devel zlib-ng-compat-devel libpng-devel
|
|
||||||
cmake -S . -B build-rpm -G Ninja \
|
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
|
||||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
|
||||||
-DMOCKTAIL_PACKAGE_NAME=nightcap \
|
|
||||||
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
|
|
||||||
-DBUILD_TESTING=OFF
|
|
||||||
cmake --build build-rpm --parallel 4
|
|
||||||
cpack --config build-rpm/CPackConfig.cmake \
|
|
||||||
-G RPM -B dist/rpm
|
|
||||||
rpm --query --package --info dist/rpm/*.rpm >/dev/null
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Upload RPM
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-RPM
|
|
||||||
path: dist/rpm/*.rpm
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
arch:
|
|
||||||
name: Pacman package (Arch Linux)
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Build pacman package
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
version="$(./scripts/package_version.sh)"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${version}/" packaging/arch/PKGBUILD
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
|
||||||
--workdir /workspace \
|
|
||||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
|
||||||
pacman -Syu --needed --noconfirm \
|
|
||||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
|
||||||
nlohmann-json libyaml libelf minizip capstone gtk4 \
|
|
||||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libpng \
|
|
||||||
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
|
|
||||||
useradd --create-home builder
|
|
||||||
chown -R builder:builder /workspace
|
|
||||||
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
|
|
||||||
makepkg --dir /workspace/packaging/arch \
|
|
||||||
--noconfirm --cleanbuild --force
|
|
||||||
install -d dist/arch
|
|
||||||
cp packaging/arch/*.pkg.tar.zst dist/arch/
|
|
||||||
pacman -Qp dist/arch/*.pkg.tar.zst >/dev/null
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Upload pacman package
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: Nightcap-Arch
|
|
||||||
path: dist/arch/*.pkg.tar.zst
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish GitHub release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
needs: [appimage, deb, rpm, arch]
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
|
|
||||||
- name: Download packages
|
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
pattern: 'Nightcap-*'
|
|
||||||
merge-multiple: true
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Download AppImage
|
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
name: Mocktail-AppImage
|
|
||||||
path: release
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
tag="${GITHUB_REF_NAME}"
|
|
||||||
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
|
|
||||||
# An unmatched glob would otherwise reach sha256sum and gh as its own
|
|
||||||
# literal text, so every package pattern has to match before upload.
|
|
||||||
shopt -s nullglob
|
|
||||||
assets=(Nightcap-x86_64.AppImage)
|
|
||||||
for glob in '*.deb' '*.rpm' '*.pkg.tar.zst'; do
|
|
||||||
matched=(release/$glob)
|
|
||||||
if [[ ${#matched[@]} -eq 0 ]]; then
|
|
||||||
echo "::error::no ${glob} package among the release artifacts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assets+=("${matched[@]#release/}")
|
|
||||||
done
|
|
||||||
uploads=()
|
|
||||||
for asset in "${assets[@]}"; do
|
|
||||||
uploads+=("release/$asset" "release/$asset.sha256")
|
|
||||||
done
|
|
||||||
(
|
|
||||||
cd release
|
|
||||||
for asset in "${assets[@]}"; do
|
|
||||||
sha256sum "$asset" > "$asset.sha256"
|
|
||||||
done
|
|
||||||
)
|
|
||||||
./scripts/release_notes.sh "$tag" > release/notes.md
|
|
||||||
# The Flatpak workflow creates the same release; whichever job
|
|
||||||
# arrives second sees "already exists" and continues.
|
|
||||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "Nightcap $tag" --notes-file release/notes.md ||
|
|
||||||
gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null
|
|
||||||
fi
|
|
||||||
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "Nightcap $tag" --notes-file release/notes.md
|
|
||||||
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
|
|
||||||
"${uploads[@]}"
|
|
||||||
|
|
||||||
# nightcap-bin downloads the pacman package from the release page, so this
|
|
||||||
# waits for the upload.
|
|
||||||
aur:
|
|
||||||
name: Publish AUR packages
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
needs: publish
|
|
||||||
uses: ./.github/workflows/aur-publish.yml
|
|
||||||
with:
|
|
||||||
tag: ${{ github.ref_name }}
|
|
||||||
secrets:
|
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
name: Steam Frame ARM64
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [steam-frame]
|
||||||
|
tags: ['steam-frame-*']
|
||||||
|
pull_request:
|
||||||
|
branches: [steam-frame]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish_release:
|
||||||
|
description: Publish a release for an existing steam-frame-* tag
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
release_tag:
|
||||||
|
description: Existing tag to build when publishing (leave empty for artifact only)
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: steam-frame-arm64-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: localhost/nightcap-vr-arm64:frame-foveated
|
||||||
|
ASSET: nightcap-steam-frame-arm64.oci.tar.zst
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
source-checks:
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release && inputs.release_tag || github.ref }}
|
||||||
|
- name: Check Native settings and package scripts
|
||||||
|
run: |
|
||||||
|
for script in build-frame-nightcap.sh packaging/steam-frame/verify_image.sh; do
|
||||||
|
bash -n "$script"
|
||||||
|
done
|
||||||
|
python3 - <<'PY'
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
paths = list(Path('tools/steam_frame').glob('*.py'))
|
||||||
|
paths.append(Path('tools/steam_frame/nightcap-vr-arm64'))
|
||||||
|
for path in paths:
|
||||||
|
ast.parse(path.read_text(), filename=str(path))
|
||||||
|
PY
|
||||||
|
python3 -m unittest discover -s tests -p 'steam_frame_*_test.py'
|
||||||
|
node tests/steamvr_video_controls_test.js
|
||||||
|
|
||||||
|
build:
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
needs: source-checks
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 90
|
||||||
|
outputs:
|
||||||
|
release_tag: ${{ steps.release-target.outputs.tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release && inputs.release_tag || github.ref }}
|
||||||
|
- name: Validate release target
|
||||||
|
id: release-target
|
||||||
|
env:
|
||||||
|
EVENT: ${{ github.event_name }}
|
||||||
|
REF_TYPE: ${{ github.ref_type }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
PUBLISH: ${{ inputs.publish_release }}
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
run: |
|
||||||
|
tag=''
|
||||||
|
if [[ "$EVENT" == push && "$REF_TYPE" == tag ]]; then
|
||||||
|
tag="$REF_NAME"
|
||||||
|
elif [[ "$EVENT" == workflow_dispatch && "$PUBLISH" == true ]]; then
|
||||||
|
tag="$REQUESTED_TAG"
|
||||||
|
fi
|
||||||
|
if [[ -n "$tag" || "$PUBLISH" == true ]]; then
|
||||||
|
[[ "$tag" =~ ^steam-frame-[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
|
||||||
|
[[ "$(git rev-parse "refs/tags/$tag^{commit}")" == "$(git rev-parse HEAD)" ]]
|
||||||
|
fi
|
||||||
|
printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Install container tools
|
||||||
|
run: |
|
||||||
|
test "$(uname -m)" = aarch64
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends -y podman zstd
|
||||||
|
- name: Build Native ARM64 image
|
||||||
|
run: |
|
||||||
|
sudo podman build --arch arm64 --jobs 1 \
|
||||||
|
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
|
||||||
|
--label "org.opencontainers.image.revision=$(git rev-parse HEAD)" \
|
||||||
|
--label org.opencontainers.image.title='Nightcap Steam Frame ARM64' \
|
||||||
|
-f packaging/steam-frame/Containerfile -t "$IMAGE" .
|
||||||
|
- name: Run mirror and runtime configuration regressions
|
||||||
|
run: |
|
||||||
|
sudo podman run --rm --entrypoint /bin/bash "$IMAGE" -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
cmake -S /work -B /work/build-frame-arm64 -DBUILD_TESTING=ON
|
||||||
|
cmake --build /work/build-frame-arm64 -j3 \
|
||||||
|
--target desktop_mirror_test runtime_config_test runtime_config_file_test
|
||||||
|
/work/build-frame-arm64/tests/desktop_mirror_test
|
||||||
|
/work/build-frame-arm64/tests/runtime_config_test
|
||||||
|
/work/build-frame-arm64/tests/runtime_config_file_test
|
||||||
|
'
|
||||||
|
- name: Check payload-free image
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
sudo bash packaging/steam-frame/verify_image.sh "$IMAGE" > dist/image-check.json
|
||||||
|
cat dist/image-check.json
|
||||||
|
- name: Export image and checksums
|
||||||
|
run: |
|
||||||
|
sudo podman save --format oci-archive "$IMAGE" | zstd -T2 -6 -o "dist/$ASSET"
|
||||||
|
test "$(stat -c %s "dist/$ASSET")" -lt 2147483648
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, pathlib, subprocess, tarfile
|
||||||
|
archive = pathlib.Path('dist') / os.environ['ASSET']
|
||||||
|
process = subprocess.Popen(['zstd', '-dc', str(archive)], stdout=subprocess.PIPE)
|
||||||
|
index = None
|
||||||
|
with tarfile.open(fileobj=process.stdout, mode='r|') as stream:
|
||||||
|
for member in stream:
|
||||||
|
if member.name == 'index.json':
|
||||||
|
index = json.load(stream.extractfile(member))
|
||||||
|
while process.stdout.read(1024 * 1024):
|
||||||
|
pass
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise SystemExit('Compressed OCI archive integrity check failed')
|
||||||
|
manifests = index.get('manifests', []) if index else []
|
||||||
|
if (len(manifests) != 1 or manifests[0].get('annotations', {}).get(
|
||||||
|
'org.opencontainers.image.ref.name') != os.environ['IMAGE']):
|
||||||
|
raise SystemExit('OCI archive must restore the normal launcher image tag')
|
||||||
|
print('Compressed OCI archive and launcher tag verified')
|
||||||
|
PY
|
||||||
|
cd dist
|
||||||
|
sha256sum "$ASSET" > "$ASSET.sha256"
|
||||||
|
- name: Write build metadata
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ steps.release-target.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, pathlib, subprocess
|
||||||
|
commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
|
||||||
|
image_id = subprocess.check_output(['sudo', 'podman', 'image', 'inspect', '--format', '{{.Id}}', os.environ['IMAGE']], text=True).strip()
|
||||||
|
metadata = {
|
||||||
|
'source': 'https://github.com/' + os.environ['GITHUB_REPOSITORY'],
|
||||||
|
'commit': commit, 'release_tag': os.environ['RELEASE_TAG'] or None,
|
||||||
|
'platform': 'linux/arm64', 'image_tag': os.environ['IMAGE'], 'image_id': image_id,
|
||||||
|
'archive': os.environ['ASSET'], 'upscaling': 'Native', 'fsr3_compiled': False,
|
||||||
|
'default_eye_size': [2160, 2160], 'default_refresh_hz': 72,
|
||||||
|
'default_antialiasing': 'SMAA High',
|
||||||
|
'checks': ['Native single-sample policy', 'refresh-rate policy', 'desktop mirror policy', 'video controls',
|
||||||
|
'mirror command recording and runtime configuration',
|
||||||
|
'AArch64 ELF and dynamic dependencies', 'payload and license packaging',
|
||||||
|
'compressed OCI integrity and launcher tag'],
|
||||||
|
'runtime_testing': 'CI packaging checks do not start SteamVR or Roblox',
|
||||||
|
'licenses': 'Source notices and third-party notices are included under /work in the image',
|
||||||
|
}
|
||||||
|
pathlib.Path('dist/build-info.json').write_text(json.dumps(metadata, indent=2) + '\n')
|
||||||
|
PY
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: nightcap-steam-frame-arm64
|
||||||
|
path: dist/
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: needs.build.outputs.release_tag != ''
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
with:
|
||||||
|
name: nightcap-steam-frame-arm64
|
||||||
|
path: dist
|
||||||
|
- name: Publish checked ARM64 package
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
sha256sum --check "$ASSET.sha256"
|
||||||
|
printf '%s\n' \
|
||||||
|
'Native ARM64 Steam Frame image and packaging-check metadata.' \
|
||||||
|
'Roblox, SteamVR and host graphics drivers are downloaded or provided separately.' \
|
||||||
|
'Verify the SHA256, then import with: zstd -dc nightcap-steam-frame-arm64.oci.tar.zst | podman load' \
|
||||||
|
'See docs/INSTALL.md in this tag for launcher and SteamVR setup.' > release-notes.md
|
||||||
|
gh release create "$RELEASE_TAG" --verify-tag \
|
||||||
|
--title "Steam Frame ARM64 $RELEASE_TAG" --notes-file release-notes.md \
|
||||||
|
"$ASSET" "$ASSET.sha256" image-check.json build-info.json
|
||||||
+17
@@ -180,3 +180,20 @@ lsan.log*
|
|||||||
|
|
||||||
# Working plans, not part of the shipped documentation
|
# Working plans, not part of the shipped documentation
|
||||||
/docs/plans/
|
/docs/plans/
|
||||||
|
|
||||||
|
# Steam Frame local builds and generated SteamVR assets
|
||||||
|
/build-frame-arm64/
|
||||||
|
/frame-ui-local/
|
||||||
|
/tools/steam_frame/steamvr-*.original.*
|
||||||
|
/tools/steam_frame/steamvr-*.sharpening.*
|
||||||
|
/tools/steam_frame/steamvr-sharpening-ui-patch.json
|
||||||
|
/steam-shortcut.json
|
||||||
|
|
||||||
|
# Local Frame payloads, images and source-transfer archives
|
||||||
|
/data/
|
||||||
|
/host-drivers/
|
||||||
|
*.bundle
|
||||||
|
*.oci
|
||||||
|
*.img
|
||||||
|
/current.json
|
||||||
|
/previous_good.json
|
||||||
@@ -4,3 +4,6 @@
|
|||||||
[submodule "third_party/Vulkan-Headers"]
|
[submodule "third_party/Vulkan-Headers"]
|
||||||
path = third_party/Vulkan-Headers
|
path = third_party/Vulkan-Headers
|
||||||
url = https://github.com/KhronosGroup/Vulkan-Headers.git
|
url = https://github.com/KhronosGroup/Vulkan-Headers.git
|
||||||
|
[submodule "third_party/OpenXR-SDK"]
|
||||||
|
path = third_party/OpenXR-SDK
|
||||||
|
url = https://github.com/KhronosGroup/OpenXR-SDK.git
|
||||||
-301
@@ -1,301 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to Nightcap. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
||||||
Release notes on GitHub are taken from the matching section here.
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox
|
|
||||||
ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox
|
|
||||||
updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to
|
|
||||||
@dim-ghub.
|
|
||||||
- The asset overlay folder now also replaces files Roblox downloads and no longer ships, such as the shift
|
|
||||||
lock and text cursors. Put the file under `content/`, at the path Roblox uses. Thanks to @dim-ghub for the
|
|
||||||
idea.
|
|
||||||
- `MOCKTAIL_ASSET_TRACE=1` now also prints the files Roblox asks for and does not get, as `[asset] miss`.
|
|
||||||
- Arch Linux: `nightcap-git` is on the AUR and builds the current `main` branch. Thanks to @dim-ghub, who
|
|
||||||
maintains it.
|
|
||||||
- The log now names each Roblox Java call that Nightcap has no handler for, once per call, as
|
|
||||||
`[JNI] no handler for <class>.<method><signature>`. This shows what a new Roblox version needs.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates
|
|
||||||
memory for each block of sound it plays.
|
|
||||||
|
|
||||||
## [0.3.4] - 2026-10-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- The AppImage build now makes a `.zsync` file, so tools like `appimageupdatetool` can update the AppImage.
|
|
||||||
- The desktop side panel menu now shows by default on PC profiles. Set `FIntFlyoutTestVariantRollout12` in
|
|
||||||
`fflags.json` to change it.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- The Flatpak build asks for the X11 socket only when there is no Wayland session, as before 0.3.3.
|
|
||||||
- Nightcap now uses Wayland on a Wayland session with NVIDIA drivers too, however it is started. It used to
|
|
||||||
fall back to X11 there when started from a terminal.
|
|
||||||
- Building Nightcap no longer needs libexecinfo.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed Roblox 2.742 being rejected as an update, which left the game on the older version. Nightcap now
|
|
||||||
accepts Roblox versions that change the order of their start-up steps.
|
|
||||||
- Fixed Nightcap reporting Mocktail's version number, 1.0.4, in session logs and support bundles. It now
|
|
||||||
reports its own version.
|
|
||||||
- Fixed the old Roblox home screen showing instead of the new one with the Me tab and the "For you" and
|
|
||||||
"Charts" tabs. Nightcap now runs the Roblox start-up step that loads the new interface for your account.
|
|
||||||
- Fixed anti-aliasing being forced off at every graphics quality level. Roblox now picks it, and a
|
|
||||||
`FIntDebugForceMSAASamples` value in `fflags.json` still applies. Thanks to @qutad.
|
|
||||||
- Fixed the voice chat microphone only working on Roblox 2.738. It no longer depends on one Roblox version.
|
|
||||||
- Fixed other players not hearing you in voice chat after a Roblox update. The updater now rebuilds the
|
|
||||||
audio device data when the previous version's profile has none.
|
|
||||||
- Fixed frame hitches while textures load on graphics cards that need ETC2 textures decoded on the CPU.
|
|
||||||
- Fixed problems on Chimera Linux and other musl systems, including error messages that differed from the
|
|
||||||
ones Roblox expects.
|
|
||||||
- Fixed the nightly AppImage build failing when a bundled GTK 4 was older than the one libadwaita needs.
|
|
||||||
- Fixed a random crash on Wayland with NVIDIA drivers, where the game window closed after you let go of the
|
|
||||||
camera or left mouse lock. The log showed `wp_linux_drm_syncobj_surface_v1` error 3.
|
|
||||||
- Fixed some menu actions being ignored. The Chat button on a profile now opens the Party tab with that
|
|
||||||
player, and clicking a search suggestion now runs the search instead of closing the search menu.
|
|
||||||
- Fixed possible crashes when the game calls an Android function Nightcap did not provide, or releases the
|
|
||||||
same reference twice.
|
|
||||||
- Fixed a possible hang on close when a web view process stayed open. Nightcap now stops waiting for the log
|
|
||||||
writer after 2 seconds.
|
|
||||||
- Fixed a possible missing picture on graphics drivers that reject the low-latency present mode. Nightcap now
|
|
||||||
retries with the game's own mode.
|
|
||||||
- Fixed a possible Wayland crash when the window first appears on the OpenGL graphics path.
|
|
||||||
- Fixed a missing picture on machines where the graphics card runs a different driver than the one Nightcap
|
|
||||||
picked, such as an NVIDIA card on nouveau or a laptop with an Intel chip and an older second card. Nightcap
|
|
||||||
now picks the Vulkan driver that matches the running kernel driver, and says so when no card can run Vulkan.
|
|
||||||
|
|
||||||
## [0.3.3] - 2026-10-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks up which game they are in and
|
|
||||||
joins it.
|
|
||||||
- Nix users get a desktop entry for the app.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Roblox textures now decode about 3 times faster, which shortens the stalls when a place loads and when new
|
|
||||||
parts of the map stream in. Thanks to @glook9001.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `make update-roblox` no longer deletes your previous Roblox install when an import fails partway through. If
|
|
||||||
a step fails, the old files are put back, or kept in a folder whose path is printed. Thanks to @kimlherme.
|
|
||||||
- A failed Roblox update now restores your previous version's records instead of leaving them half switched. A
|
|
||||||
damaged version file is rejected instead of being half read, and a temporary failure to start the startup
|
|
||||||
check no longer marks a working Roblox build as broken for good. Thanks to @kimlherme.
|
|
||||||
- Fixed a possible crash when Roblox clears its audio queue while sound is still playing, and a memory leak in
|
|
||||||
lists of objects passed to Roblox's Java code. Thanks to @kimlherme.
|
|
||||||
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox releases the mouse lock while
|
|
||||||
another release is already in progress, and network lookups that misread Roblox's options. Thanks to
|
|
||||||
@kimlherme.
|
|
||||||
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan renderer.
|
|
||||||
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox decides when a click ends
|
|
||||||
typing.
|
|
||||||
- Going to another page in the web view, such as Friends from a profile, no longer closes the web view. It
|
|
||||||
crashed on every page change on Wayland systems whose GL setup GTK could not use.
|
|
||||||
- Fixed a crash when Roblox's saved dark or light theme setting was missing or damaged.
|
|
||||||
|
|
||||||
## [0.3.2] - 2026-09-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Performance recordings made with `--profile` now show how busy the CPU was during each frame, which makes it
|
|
||||||
easier to tell what caused a stutter.
|
|
||||||
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android `arm64-v8a` client.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- When Roblox asks for a Google Play device check during login, Nightcap now answers right away that it isn't
|
|
||||||
supported. Before, the "Verifying you're not a bot" screen waited about a minute for an answer and then sent
|
|
||||||
you back to the login screen.
|
|
||||||
- Solving a login challenge now closes its window. Before, Roblox's "challenge completed" signal was ignored
|
|
||||||
and the window stayed open. Thanks to @glook9001.
|
|
||||||
- Login no longer stalls after you pass a verification check. Before, Roblox only saw the page's "challenge
|
|
||||||
completed" signal after a one-minute timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
|
||||||
can't handle this way, the log says "WebViewProtocol getter not recognized" and web pages behave as before.
|
|
||||||
- Login check windows and other web pages get 5 seconds to start instead of 3, so a slow start no longer
|
|
||||||
closes them. The log says how long each one took to open.
|
|
||||||
- Less stutter while joining a game and while textures load in. In Blade Ball, loading hitches dropped by
|
|
||||||
about half, and the worst one went from 0.8 seconds to 0.55 seconds.
|
|
||||||
- Clicking Play on a private server now joins right away. Before, nothing happened until you clicked a sidebar
|
|
||||||
tab like Home.
|
|
||||||
- Middle click now reaches Roblox as the middle mouse button.
|
|
||||||
- Scrolling follows your system's natural scrolling setting.
|
|
||||||
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other than the one making the call.
|
|
||||||
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles for the same version.
|
|
||||||
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or `MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID`
|
|
||||||
is now ignored instead of being partly read.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by default instead of 4, so a local
|
|
||||||
Flatpak build is less likely to run out of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use
|
|
||||||
more. Thanks to @glook9001.
|
|
||||||
|
|
||||||
## [0.3.1] - 2026-09-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Each release carries a DEB, an RPM, and a pacman package beside the AppImage, with a `.sha256` for every
|
|
||||||
file. They install `/usr/bin/mocktail` and conflict with each other and with upstream's `mocktail` package.
|
|
||||||
Nightcap is in no distribution repository or the AUR; the recipes under `packaging/aur/nightcap{,-bin,-git}`
|
|
||||||
build from this repository locally.
|
|
||||||
- The AppStream metadata lists the Nightcap releases, so software centres show the release history instead of
|
|
||||||
upstream's.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- ETC2 texture decoding no longer blocks the thread that submits the frame. Uploads decode on a background
|
|
||||||
dispatcher and the submit carries a timeline semaphore wait, so the GPU waits for the texture instead of the
|
|
||||||
render thread. Reading the application's compressed bytes moves to that dispatcher too whenever they are
|
|
||||||
already mapped. In Blade Ball the worst `vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to
|
|
||||||
0.05ms, and the total time spent in submits from 2534ms to 309ms. Frame interval is unchanged: the remaining
|
|
||||||
tail is the engine's own main-thread step. A device without the `timelineSemaphore` feature decodes inline
|
|
||||||
as before, as does a submit whose `pNext` already sizes arrays by its semaphore counts.
|
|
||||||
- The mouse pointer jumped when a right-click camera drag ended, and drifted to the viewport edge during one.
|
|
||||||
The input router inferred pointer capture from SDL reporting zero absolute coordinates, which holds on X11
|
|
||||||
but not on Wayland, where the reported position accumulates the relative deltas and leaves the window.
|
|
||||||
Motion, button, and wheel events now carry the window's relative mouse mode. The right-click camera fallback
|
|
||||||
also only captures inside an experience, so the cursor stays free over the app surface.
|
|
||||||
- Shutting the window down while an experience was starting or leaving could free the pointer capture owner
|
|
||||||
underneath the thread using it. Callers off the SDL thread now hold a reference for the length of their
|
|
||||||
call.
|
|
||||||
- The TextBox caret did not move when a trailing space was typed in a single-line field. The final cluster
|
|
||||||
rect SDL_ttf reports stops at the last inked glyph, so the laid-out width is used there instead. Wrapped
|
|
||||||
layouts keep the clamp, because their width is the widest line rather than the last.
|
|
||||||
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six `TTF_OpenFont` calls, and `TTF_Quit` on
|
|
||||||
the render thread. The overlay holds its opened fonts and reopens them only when the Roblox font or the
|
|
||||||
point size changes, keeping the current set when a reopen fails. Measured over 100 rasters, 1.59ms to 0.12ms
|
|
||||||
per keystroke.
|
|
||||||
- Staged ETC2 copies now carry an explicit host-write barrier. A queue submission only makes host writes from
|
|
||||||
before it visible to the device, and an asynchronous decode writes after the submit, so without the barrier
|
|
||||||
those texels were not guaranteed to reach the copy that reads them.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A `desiredgov=powersave` in
|
|
||||||
`~/.config/gamemode.ini` overrides the packaged `performance` default and clocks the CPU down for the whole
|
|
||||||
session while still reporting an active performance request.
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-09-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the name in "Playing Nightcap", and
|
|
||||||
`integrations.discord_rpc.images` sets the large and small icons and their hover text. Text and image fields
|
|
||||||
accept `{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands an empty placeholder is
|
|
||||||
left out, and `text.state: ""` hides the state line.
|
|
||||||
- The default Discord card now matches Sober: the title is "Roblox", the details line is "Playing
|
|
||||||
<experience>", the state is "by <creator>", and the Roblox badge sits inside the experience icon. The
|
|
||||||
creator comes from the same Roblox games request that already fetches the experience name. With
|
|
||||||
`show_place_name: false` the creator is hidden too, so the card shows only the title and timer.
|
|
||||||
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's work: presents, GPU waits, submits,
|
|
||||||
ETC2 texture decode, and pipeline and shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
|
|
||||||
describes the benchmark runs, and `scripts/summarize_profile_trace.py` turns traces into a before and after
|
|
||||||
table.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Texture uploads stalled the frame they were submitted on. ETC2 batches decoded on worker threads but were
|
|
||||||
then resampled one upload at a time on the submitting thread, which at the default 4x upscale is sixteen
|
|
||||||
times the decoded texels. Resampling now runs on the same worker pool, in two passes so mip level 0 still
|
|
||||||
resolves before the mips built from it. In a 70k-part place the worst `vkQueueSubmit` fell from 167ms to
|
|
||||||
92ms.
|
|
||||||
- Most ETC2 batches decoded on a single thread. Workers were created and joined per batch, so the parallel
|
|
||||||
floor had to cover that cost and sat at a 1024x1024 image; batches under it ran on the caller, which was 54%
|
|
||||||
of all decode time in a 70k-part place. Decode workers are now a persistent pool, and the floor drops to a
|
|
||||||
128x128 image. Mean decode time in that place fell from 8.2ms to 4.7ms, and 11.8ms before both texture
|
|
||||||
fixes.
|
|
||||||
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to the few fastest cores,
|
|
||||||
concentrating Roblox's worker pool onto them instead of spreading it. With `performance.gamemode: auto`, a
|
|
||||||
request that narrows the CPU affinity is now released again, which releases the pin. Set `pin_cores=no` in
|
|
||||||
`~/.config/gamemode.ini` to keep GameMode without the pinning, or `performance.gamemode: on` to accept it.
|
|
||||||
See `docs/PERFORMANCE.md`.
|
|
||||||
- With `show_place_name: false`, Discord no longer shows the experience thumbnail or its name on hover.
|
|
||||||
- The host main thread no longer spins on the engine's message pump, which cost about half a core and 146,000
|
|
||||||
empty calls a second. It now rests 100 µs between polls: sleeping when the governor is `performance`,
|
|
||||||
parking the core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode setting the governor,
|
|
||||||
process CPU in a 60k-part place fell from 216% to 107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST`
|
|
||||||
forces a mode; see `docs/PERFORMANCE.md`.
|
|
||||||
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than the image read the padded rows as
|
|
||||||
packed data and decoded garbage.
|
|
||||||
- ETC2 resampling created and joined worker threads on every batch. It now runs on the persistent decode pool.
|
|
||||||
- A failed trace write left the profile writer thread waking every 250 ms for the rest of the session. It now
|
|
||||||
exits.
|
|
||||||
- The `--profile` trace gains a `pump` slice for the engine's main-thread step.
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-09-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
|
|
||||||
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's own units. Turn it on when
|
|
||||||
first-person aiming feels too slow or too fast under fractional display scaling. Off by default.
|
|
||||||
- Nightly AppImage builds, published as the `continuous` prerelease. They are untested; use a tagged release
|
|
||||||
for normal play.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Values in `fflags.json` now win over the performance preset instead of stopping the launch. Startup prints
|
|
||||||
each key it kept.
|
|
||||||
- Discord Rich Presence uses Nightcap's own Discord application, so it shows as "Playing Nightcap". Terms and
|
|
||||||
privacy pages for it live on the project site.
|
|
||||||
- Discord Rich Presence is on by default. Set `integrations.discord_rpc.enabled: false` to turn it off.
|
|
||||||
- The AppImage build verifies its reduced dependency packages against digests taken at build time, rather than
|
|
||||||
a digest list kept in the repository.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Mouse look keeps responding after a long turn. The tracked pointer position could drift past the edge of the
|
|
||||||
view, which swallowed every reversal until the mouse travelled all the way back.
|
|
||||||
- Nightcap no longer refuses to start with `cannot lock appStorage`. It created its own credential directory
|
|
||||||
using the process umask, then rejected that directory for being group writable. Systems with a permissive
|
|
||||||
umask, such as `002`, failed on first run.
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-15
|
|
||||||
|
|
||||||
First Nightcap release, forked from komaruworld/mocktail.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed mobile textures render correctly on
|
|
||||||
desktop GPUs. Decoding runs on worker threads to avoid stutter.
|
|
||||||
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload hash, with a dump mode to find the
|
|
||||||
texture you want to replace.
|
|
||||||
- Small textures are upscaled 4x by default. Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
|
|
||||||
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo is published at
|
|
||||||
https://coderdayton.github.io/nightcap/.
|
|
||||||
- Roadmap in `docs/ROADMAP.md`.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Frames are presented as soon as they are ready instead of waiting in a queue, which lowers input latency.
|
|
||||||
- Roblox now sees the real host RAM and display size instead of a low-end phone profile.
|
|
||||||
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to upstream Mocktail. The binary and
|
|
||||||
config paths keep the `mocktail` name.
|
|
||||||
- New name, icon, and colors.
|
|
||||||
- The desktop entry launches on Wayland and points at the installed binary.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Servers no longer kick with error 319 shortly after joining.
|
|
||||||
- The FMOD output-device bridge is optional, so the updater no longer fails without it.
|
|
||||||
- The AppImage build no longer fails on stale checksums for the slimmed Arch packages.
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.4...HEAD
|
|
||||||
[0.3.4]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.4
|
|
||||||
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
|
|
||||||
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
|
|
||||||
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
|
|
||||||
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
|
|
||||||
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
|
|
||||||
[0.1.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.1.0
|
|
||||||
@@ -126,6 +126,8 @@ set(VULKAN_HEADERS_ENABLE_INSTALL OFF CACHE BOOL
|
|||||||
"Install vendored Vulkan-Headers" FORCE)
|
"Install vendored Vulkan-Headers" FORCE)
|
||||||
add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)
|
add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)
|
||||||
|
|
||||||
|
include(${CMAKE_SOURCE_DIR}/cmake/MocktailVR.cmake)
|
||||||
|
|
||||||
include(CTest)
|
include(CTest)
|
||||||
if(BUILD_TESTING)
|
if(BUILD_TESTING)
|
||||||
# Package builds disable tests and do not fetch Google Test.
|
# Package builds disable tests and do not fetch Google Test.
|
||||||
@@ -279,6 +281,7 @@ add_library(mocktail_runtime STATIC
|
|||||||
src/runtime/game_session_coordinator.cc
|
src/runtime/game_session_coordinator.cc
|
||||||
src/runtime/graphics_launch_policy.cc
|
src/runtime/graphics_launch_policy.cc
|
||||||
src/runtime/http_client_policy.cc
|
src/runtime/http_client_policy.cc
|
||||||
|
src/runtime/luau_codegen_switch.cc
|
||||||
src/runtime/memory_limit.cc
|
src/runtime/memory_limit.cc
|
||||||
src/runtime/owned_pthread.cc
|
src/runtime/owned_pthread.cc
|
||||||
src/runtime/performance_policy.cc
|
src/runtime/performance_policy.cc
|
||||||
@@ -296,6 +299,7 @@ add_library(mocktail_runtime STATIC
|
|||||||
src/runtime/roblox_account_bridge.cc
|
src/runtime/roblox_account_bridge.cc
|
||||||
src/runtime/roblox_message_bus_request_bridge.cc
|
src/runtime/roblox_message_bus_request_bridge.cc
|
||||||
src/runtime/roblox_call_protocol_bridge.cc
|
src/runtime/roblox_call_protocol_bridge.cc
|
||||||
|
src/runtime/roblox_haptic_protocol_bridge.cc
|
||||||
src/runtime/payload_update_preflight.cc
|
src/runtime/payload_update_preflight.cc
|
||||||
src/runtime/process_launch_policy.cc
|
src/runtime/process_launch_policy.cc
|
||||||
src/runtime/process_diagnostics.cc
|
src/runtime/process_diagnostics.cc
|
||||||
@@ -599,6 +603,7 @@ target_compile_definitions(mocktail PRIVATE
|
|||||||
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
||||||
)
|
)
|
||||||
target_link_libraries(mocktail PRIVATE
|
target_link_libraries(mocktail PRIVATE
|
||||||
|
Mocktail::VR
|
||||||
mocktail_legacy_runtime
|
mocktail_legacy_runtime
|
||||||
mocktail_auth_runtime_composition
|
mocktail_auth_runtime_composition
|
||||||
mocktail_failure_dialog_runtime
|
mocktail_failure_dialog_runtime
|
||||||
@@ -815,3 +820,17 @@ add_dependencies(mocktail mocktail_webview_helper)
|
|||||||
if(BUILD_TESTING)
|
if(BUILD_TESTING)
|
||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Native SteamVR identity/settings helper; a background client never takes scene focus.
|
||||||
|
add_executable(nightcap-vr-settings tools/frame_video_settings/nightcap-vr-settings.cc)
|
||||||
|
target_include_directories(nightcap-vr-settings PRIVATE tools/frame_video_settings)
|
||||||
|
target_link_libraries(nightcap-vr-settings PRIVATE ${CMAKE_DL_LIBS})
|
||||||
|
install(TARGETS nightcap-vr-settings RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||||
|
|
||||||
|
if(MOCKTAIL_ENABLE_VR)
|
||||||
|
add_executable(nightcap-vr-performance tools/frame_video_settings/performance_overlay.cc)
|
||||||
|
target_include_directories(nightcap-vr-performance PRIVATE include tools/frame_video_settings)
|
||||||
|
target_link_libraries(nightcap-vr-performance PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf
|
||||||
|
PkgConfig::FONTCONFIG ${CMAKE_DL_LIBS})
|
||||||
|
install(TARGETS nightcap-vr-performance RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||||
|
endif()
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
|
|
||||||
# FAQ
|
|
||||||
|
|
||||||
## How do I turn Discord RPC off?
|
|
||||||
|
|
||||||
RPC is on by default. It never signs in to Discord and never reads an
|
|
||||||
account token. To turn it off, launch Nightcap once to create the config,
|
|
||||||
then close it. Open `config.yaml`:
|
|
||||||
|
|
||||||
- Native / AppImage: `~/.config/mocktail/config.yaml`
|
|
||||||
- Flatpak: `~/.var/app/io.github.CoderDayton.nightcap/config/mocktail/config.yaml`
|
|
||||||
|
|
||||||
For native installs, a custom `$XDG_CONFIG_HOME` replaces `~/.config`.
|
|
||||||
|
|
||||||
Set `enabled` to `false` under `integrations.discord_rpc`. If the block is
|
|
||||||
missing, add it under the existing `integrations` section:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
integrations:
|
|
||||||
discord_rpc:
|
|
||||||
enabled: false
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the file and restart Nightcap. Set `enabled` back to `true` to turn
|
|
||||||
RPC on again.
|
|
||||||
|
|
||||||
## Can Discord count Nightcap as Roblox play time or quests?
|
|
||||||
|
|
||||||
No. Discord only credits play time and quests to games its own scanner
|
|
||||||
detects, and Rich Presence never counts. On Linux the scanner only matches
|
|
||||||
games with a Linux entry in Discord's list. Roblox has none, so no process
|
|
||||||
name Nightcap could use would be detected. Only Roblox or Discord can change
|
|
||||||
that.
|
|
||||||
|
|
||||||
## Where can I find logs for a bug report?
|
|
||||||
|
|
||||||
Nightcap saves logs automatically. Reproduce the issue, close Nightcap, and
|
|
||||||
attach `latest.log`:
|
|
||||||
|
|
||||||
- Native / AppImage: `~/.local/state/mocktail/logs/latest.log`
|
|
||||||
- Flatpak: `~/.var/app/io.github.CoderDayton.nightcap/.local/state/mocktail/logs/latest.log`
|
|
||||||
|
|
||||||
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
|
|
||||||
|
|
||||||
## Can I run multiple Roblox instances?
|
|
||||||
|
|
||||||
No. Nightcap won't add multi-instance launching. It would mean working around
|
|
||||||
Roblox's restrictions on running several clients. The [Roblox Terms of Use](https://en.help.roblox.com/hc/en-us/articles/115004647846-Roblox-Terms-of-Use)
|
|
||||||
prohibit bypassing technical protections, and violating the terms can get your
|
|
||||||
account suspended.
|
|
||||||
|
|
||||||
Bloxstrap brought the option back in [v2.9.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.9.0)
|
|
||||||
with a "use at your own risk" warning, then removed it in
|
|
||||||
[v2.10.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.10.0)
|
|
||||||
after Roblox added measures against running multiple clients.
|
|
||||||
|
|
||||||
## Can I play in VR?
|
|
||||||
|
|
||||||
Yes, as an experiment. VR lives on Nightcap's `vr` branch and is not in the
|
|
||||||
releases yet. It works on Roblox builds 2998, 3092 and 3222. Install the
|
|
||||||
[build dependencies](README.md#building), then switch to that branch and
|
|
||||||
build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch origin
|
|
||||||
git switch vr
|
|
||||||
git submodule update --init --recursive
|
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
|
|
||||||
cmake --build build -j4
|
|
||||||
```
|
|
||||||
|
|
||||||
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build/mocktail --vr
|
|
||||||
```
|
|
||||||
|
|
||||||
That branch's
|
|
||||||
[README](https://github.com/CoderDayton/nightcap/blob/vr/README.md#experimental-vr)
|
|
||||||
and [docs/VR.md](https://github.com/CoderDayton/nightcap/blob/vr/docs/VR.md)
|
|
||||||
have the details, including how to try it without a headset.
|
|
||||||
@@ -1,253 +1,62 @@
|
|||||||
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
|
<img src="packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
|
||||||
<h1 style="margin-top: 5px;">
|
<h1>Nightcap Roblox VR for Steam Frame</h1>
|
||||||
Nightcap
|
|
||||||
<br>
|
|
||||||
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
||||||
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
|
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
|
||||||
<a href="https://discord.gg/DbAUG9hBFU"><img alt="Discord" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Fv10%2Finvites%2FDbAUG9hBFU%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&label=Discord&logo=discord&logoColor=white&color=5865F2&suffix=%20members"></a>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Nightcap is Roblox on Linux, tuned for desktop play. It is a Mocktail fork
|
Native ARM64 Roblox VR for Steam Frame, with eye-tracked foveated rendering,
|
||||||
that keeps up with Roblox updates, with extra bug fixes, custom assets and
|
controller input and SteamVR integration.
|
||||||
more. It runs the Android `x86_64` Roblox client on Linux, including a Linux
|
|
||||||
userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI
|
|
||||||
pieces the client expects, then connects them to SDL3 and Vulkan or OpenGL on
|
|
||||||
the Linux side.
|
|
||||||
|
|
||||||
Nightcap is an independent community project. It is not affiliated with Roblox
|
The standard video profile is **native 2160 × 2160 per eye**, **72 Hz**,
|
||||||
Corporation or VinegarHQ and does not distribute the Roblox client.
|
**SMAA High** and **Performance** foveation. Frame rate depends on the Roblox
|
||||||
|
experience and its graphics settings; 72 FPS is the target.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Native ARM64 execution through Nightcap's Android compatibility layer.
|
||||||
|
- Stereo OpenXR rendering, head tracking, VR controllers, haptics and an
|
||||||
|
in-headset keyboard.
|
||||||
|
- Direct eye tracking with a full-detail gaze region and gradual reductions in
|
||||||
|
peripheral shading quality.
|
||||||
|
- Adjustable gaze radius and Quality, Balanced and Performance foveation modes.
|
||||||
|
- Gaze-only FXAA, SMAA Low and SMAA High anti-aliasing.
|
||||||
|
- Native-resolution rendering with a 2160 × 2160 per-eye recommended default.
|
||||||
|
- Roblox's internal frame-rate cap follows the selected headset refresh rate.
|
||||||
|
- Renderer and session handling across menu, game and game-to-game transitions.
|
||||||
|
- Native microphone capture bridge for Roblox voice chat.
|
||||||
|
- One **Nightcap Roblox VR** Steam entry with the original Nightcap icon and an
|
||||||
|
optional desktop mirror. The mirror is off by default on Steam Frame.
|
||||||
|
- Simple **Foveated Rendering**, **Gaze sharp radius** and **Anti Aliasing**
|
||||||
|
controls in SteamVR's Video settings.
|
||||||
|
- Optional in-headset performance overlay with actual game FPS, headset Hz,
|
||||||
|
frame time, GPU time and current per-eye resolution.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install the native ARM64 build from [Releases](https://github.com/InstanceBrick/nightcap/releases),
|
||||||
|
or build it on the Frame using the [installation guide](docs/INSTALL.md).
|
||||||
|
The ARM64 package contains Nightcap and its open-source runtime dependencies.
|
||||||
|
Roblox is downloaded separately on the device. Roblox binaries, account data,
|
||||||
|
SteamVR files and the Frame's graphics drivers are not included in the package.
|
||||||
|
|
||||||
|
Current ARM64 client support: **2.742.1059 (3222)** and **2.736.1408 (2998)**.
|
||||||
|
|
||||||
|
- [Installation and updates](docs/INSTALL.md)
|
||||||
|
- [Video, input and microphone settings](docs/SETTINGS.md)
|
||||||
|
- [Building and source layout](docs/DEVELOPMENT.md)
|
||||||
|
|
||||||
## About this fork
|
## About this fork
|
||||||
|
|
||||||
Nightcap is a fork of [komaruworld/mocktail](https://github.com/komaruworld/mocktail)
|
The `steam-frame` branch is the Steam Frame edition of the `vr` branch of
|
||||||
with extra fixes for playing on a PC. The program and config paths keep the
|
[CoderDayton/nightcap](https://github.com/CoderDayton/nightcap), itself a fork of
|
||||||
`mocktail` name so upstream fixes still apply cleanly. The app ID is
|
[komaruworld/mocktail](https://github.com/komaruworld/mocktail). It adds the native
|
||||||
`io.github.CoderDayton.nightcap`, so Nightcap and upstream can be installed
|
ARM64 VR bridge and Frame rendering, input, audio and Steam integration changes.
|
||||||
side by side.
|
The original Nightcap artwork is retained. Internal executable and configuration
|
||||||
|
names keep `mocktail` for compatibility with the upstream source.
|
||||||
- **Keeps up with Roblox.** New Roblox versions work soon after they come out.
|
|
||||||
- **Plays more games.** Nightcap answers a server check that some experiences
|
|
||||||
use, so you can join and stay in those too.
|
|
||||||
- **Voice chat that keeps working.** Your microphone still works after a
|
|
||||||
Roblox update.
|
|
||||||
- **Logging in works.** Verification checks and captchas finish instead of
|
|
||||||
hanging.
|
|
||||||
- **The current Roblox look.** The new home screen and side menu, like on
|
|
||||||
Windows.
|
|
||||||
- **Your own cursors, fonts and skies.** Drop files in
|
|
||||||
`~/.local/share/mocktail/asset_overlay` and Nightcap uses them. The layout
|
|
||||||
matches Sober's `asset_overlay` folder.
|
|
||||||
- **Smoother mouse and a cooler PC.** Optional raw mouse input, and less CPU
|
|
||||||
use while idle.
|
|
||||||
|
|
||||||
Everything else works the same as upstream. See
|
|
||||||
[`textures/`](textures/README.md) for custom textures, a ready-made crosshair
|
|
||||||
and the asset overlay folder, the [roadmap](docs/ROADMAP.md) for what is
|
|
||||||
coming next, the [changelog](CHANGELOG.md) for what each release changed,
|
|
||||||
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
|
|
||||||
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
|
|
||||||
|
|
||||||
## Install with Flatpak
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
|
||||||
flatpak run io.github.CoderDayton.nightcap
|
|
||||||
```
|
|
||||||
|
|
||||||
Updates arrive through `flatpak update`. Nightcap is not on Flathub yet.
|
|
||||||
|
|
||||||
## AppImage
|
|
||||||
|
|
||||||
Grab the AppImage from the
|
|
||||||
[latest release](https://github.com/CoderDayton/nightcap/releases/latest),
|
|
||||||
make it executable, and run it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x Nightcap-x86_64.AppImage
|
|
||||||
./Nightcap-x86_64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
## Distribution packages
|
|
||||||
|
|
||||||
Each release also carries a DEB, an RPM, and a pacman package, with a
|
|
||||||
`.sha256` beside every file on the
|
|
||||||
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install ./nightcap_<version>_amd64.deb
|
|
||||||
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
|
||||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
|
|
||||||
```
|
|
||||||
|
|
||||||
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
|
|
||||||
package.
|
|
||||||
|
|
||||||
On Arch Linux, [`nightcap-git`](https://aur.archlinux.org/packages/nightcap-git)
|
|
||||||
is on the AUR. It builds the current `main` branch and is maintained by
|
|
||||||
[@dim-ghub](https://github.com/dim-ghub):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yay -S nightcap-git
|
|
||||||
```
|
|
||||||
|
|
||||||
Nightcap is in no other distribution repository.
|
|
||||||
|
|
||||||
Or build and install from source using the steps under
|
|
||||||
[Building](#building).
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
```
|
|
||||||
Roblox APK -> signature and ABI checks -> Bionic + JNI -> SDL3 + Vulkan/OpenGL
|
|
||||||
```
|
|
||||||
|
|
||||||
The APK is checked before any native code is loaded. It is downloaded on first
|
|
||||||
launch and is not bundled with Nightcap. The last working copy is kept in case
|
|
||||||
an update fails.
|
|
||||||
|
|
||||||
A leftover `.previous_good-recovery.json` in the payload store marks pending
|
|
||||||
recovery after a publication or restoration failure and blocks further payload
|
|
||||||
publication and garbage collection. Check `current.json` and `previous_good.json`
|
|
||||||
against the saved recovery state and fix the underlying problem before manually
|
|
||||||
removing the marker.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Screenshots</summary>
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## FFlag overrides
|
|
||||||
|
|
||||||
Put a JSON object in `$XDG_CONFIG_HOME/mocktail/fflags.json` (usually
|
|
||||||
`~/.config/mocktail/fflags.json`). Nightcap applies it on the next launch.
|
|
||||||
|
|
||||||
PC profiles use the legacy Charts page by default to avoid the blank screen
|
|
||||||
caused by `Color3` errors in the newer SDUI page. An explicit
|
|
||||||
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
|
|
||||||
|
|
||||||
PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the
|
|
||||||
desktop side panel menu. An explicit override takes precedence.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"FFlagExample": "True",
|
|
||||||
"DFIntExample": "120"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
## Settings
|
|
||||||
|
|
||||||
Edit `~/.config/mocktail/config.yaml`. Useful options:
|
|
||||||
|
|
||||||
- `graphics.frame_rate_limit: display` to unlock FPS to your monitor's rate.
|
|
||||||
- `graphics.vsync: off` for the lowest lag, at the cost of tearing.
|
|
||||||
- `graphics.backend: opengl` if Vulkan does not work on your GPU.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Linux `x86_64` is supported. Experimental FreeBSD 15.1 Linuxulator support has
|
|
||||||
been tested with an `x86_64` Fedora 44 userspace. On FreeBSD, Nightcap runs
|
|
||||||
inside Linuxulator; it is not a native FreeBSD binary. Building requires CMake
|
|
||||||
3.20+, Git, pkg-config, LLD, binutils, a C++17 compiler, SDL 3.4+, SDL3_ttf,
|
|
||||||
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, libpng,
|
|
||||||
minizip, Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and
|
|
||||||
WebKitGTK 6.0.
|
|
||||||
|
|
||||||
See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instructions.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Ubuntu 26.04+</summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install build-essential cmake git ninja-build pkg-config lld \
|
|
||||||
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
|
|
||||||
nlohmann-json3-dev libyaml-dev libpng-dev libelf-dev libminizip-dev \
|
|
||||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
|
||||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
|
||||||
libplacebo-dev zlib1g-dev
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Arch Linux</summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
|
|
||||||
curl openssl nlohmann-json libyaml libpng libelf minizip capstone gtk4 \
|
|
||||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
|
|
||||||
libplacebo vulkan-headers vulkan-icd-loader zlib
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Fedora 44+</summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install gcc-c++ cmake git ninja-build pkgconf-pkg-config lld \
|
|
||||||
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
|
|
||||||
nlohmann-json-devel libyaml-devel libpng-devel elfutils-libelf-devel \
|
|
||||||
minizip-ng-compat-devel \
|
|
||||||
capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \
|
|
||||||
utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \
|
|
||||||
vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
|
||||||
cd nightcap
|
|
||||||
make build
|
|
||||||
./build/mocktail
|
|
||||||
```
|
|
||||||
|
|
||||||
To install it for your user, with an app menu entry and Roblox link handling:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make install PREFIX=~/.local
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `sudo make install` instead to install system-wide under `/usr`. To make
|
|
||||||
Roblox website links open the build tree copy without installing, run
|
|
||||||
`make register-url-handler`.
|
|
||||||
|
|
||||||
To build and install the Flatpak from source, install `flatpak-builder` and run
|
|
||||||
`make flatpak`. It builds with 2 parallel jobs to keep memory use low. Pass
|
|
||||||
`FLATPAK_JOBS=N` to `make flatpak`, or set `MOCKTAIL_FLATPAK_JOBS=N` for
|
|
||||||
`scripts/build_flatpak.sh`, to change that.
|
|
||||||
|
|
||||||
To run the same checks as CI before each commit and push, install
|
|
||||||
[lefthook](https://github.com/evilmartians/lefthook) and run once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lefthook install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
Nightcap does not currently work with `hardened_malloc`. Using
|
|
||||||
`hardened_malloc` may cause Nightcap to fail to start or crash during runtime.
|
|
||||||
Run Nightcap without `hardened_malloc` enabled.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE). Third-party components keep their own licenses.
|
[Apache License 2.0](LICENSE). Original copyright notices and third-party
|
||||||
|
licenses remain in their source directories. See [third-party credits](THIRD_PARTY.md).
|
||||||
|
|
||||||
## Support
|
Nightcap is an independent community project and is not affiliated with Roblox
|
||||||
|
Corporation or Valve.
|
||||||
Support this fork by giving it a star or with cryptocurrency:
|
|
||||||
|
|
||||||
- LTC: `LdABR2ELRYrUESWZWrEk1uNx38gHHMuHUU`
|
|
||||||
- SOL: `tjeDnPoWeyW8zfTf8c5CVAm5QTV8vFPaPouaScAdwfT`
|
|
||||||
|
|
||||||
Support the upstream project, which does most of the heavy lifting:
|
|
||||||
|
|
||||||
- USDT (TON): `UQCi6Yzcc9cOctoij6n_r1K90-OdVxAT0D_xo2UzGKkQaJDY`
|
|
||||||
- USDT (TRC20): `TNPMG9Vig2xiuo2r1QqnXRChPH7Vu28Jmx`
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Credits and third-party components
|
||||||
|
|
||||||
|
This edition includes modifications for Steam Frame ARM64 and is based on:
|
||||||
|
|
||||||
|
- [Nightcap](https://github.com/CoderDayton/nightcap), including vii's changes to
|
||||||
|
Mocktail and the upstream `vr` branch.
|
||||||
|
- [Mocktail](https://github.com/komaruworld/mocktail), by the Mocktail project
|
||||||
|
authors.
|
||||||
|
|
||||||
|
The Steam Frame edition incorporates the upstream VR tree through
|
||||||
|
[`753a3f81`](https://github.com/CoderDayton/nightcap/commit/753a3f8106e4d7fdafea457601a5ff238e77c2d0)
|
||||||
|
(15 October 2026), including GPU-ordered presentation, asynchronous eye and
|
||||||
|
mirror copies, cached JNI method dispatch, headset detection and the JNI haptic
|
||||||
|
protocol. ARM64 client contracts and Frame gaze AA remain platform-specific.
|
||||||
|
|
||||||
|
The project is distributed under the [Apache License 2.0](LICENSE). Existing
|
||||||
|
copyright and modification notices are retained in the source.
|
||||||
|
|
||||||
|
Third-party components retain their own licenses and notices, including the
|
||||||
|
Android/Bionic linker sources under `third_party/mcpelauncher-linker`, the pinned
|
||||||
|
OpenXR SDK, Vulkan headers and libjnivm submodules, SMAA under `src/vr/smaa`, AMD FidelityFX FSR 1 under `src/vr/fsr`
|
||||||
|
([MIT license](src/vr/fsr/LICENSE.txt)),
|
||||||
|
AMD FidelityFX SDK 1.1.4's FSR 3.1.4 upscaler, Optical Flow component and Vulkan
|
||||||
|
backend under `third_party/fidelityfx`
|
||||||
|
([MIT license](third_party/fidelityfx/LICENSE.txt)), and
|
||||||
|
the OpenVR interface header under `tools/frame_video_settings`. The original
|
||||||
|
Nightcap icon and artwork are retained from the upstream project.
|
||||||
|
|
||||||
|
Roblox client binaries and Valve SteamVR dashboard assets are not included.
|
||||||
|
The client is downloaded separately and the Video settings tools read dashboard
|
||||||
|
files from the user's installed SteamVR copy.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1009 KiB |
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
cmake -S /work -B /work/build-frame-arm64 -G Ninja -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON -DMOCKTAIL_ENABLE_FSR3=OFF -DMOCKTAIL_VR_DIAGNOSTICS=OFF -DBUILD_TESTING=OFF -DMOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER=OFF -DCMAKE_INSTALL_PREFIX=/opt/nightcap-vr-arm64
|
||||||
|
cmake --build /work/build-frame-arm64 -j3
|
||||||
|
cmake --install /work/build-frame-arm64
|
||||||
|
c++ -std=c++17 -O2 /work/tools/frame_video_settings/nightcap-vr-settings.cc -ldl -o /opt/nightcap-vr-arm64/bin/nightcap-vr-settings
|
||||||
|
printf 'NIGHTCAP_ARM64_BUILD_COMPLETE\n'
|
||||||
@@ -22,6 +22,7 @@ add_library(mocktail_input_runtime STATIC
|
|||||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
|
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
|
||||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
|
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
|
||||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
|
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
|
||||||
|
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_xr_controller_input.cc
|
||||||
)
|
)
|
||||||
add_library(Mocktail::InputRuntime ALIAS mocktail_input_runtime)
|
add_library(Mocktail::InputRuntime ALIAS mocktail_input_runtime)
|
||||||
target_include_directories(mocktail_input_runtime PUBLIC
|
target_include_directories(mocktail_input_runtime PUBLIC
|
||||||
@@ -33,12 +34,14 @@ target_include_directories(mocktail_input_runtime PRIVATE
|
|||||||
target_link_libraries(mocktail_input_runtime PUBLIC
|
target_link_libraries(mocktail_input_runtime PUBLIC
|
||||||
Mocktail::PlatformSdl
|
Mocktail::PlatformSdl
|
||||||
Mocktail::Runtime
|
Mocktail::Runtime
|
||||||
|
Mocktail::VR
|
||||||
mocktail_window
|
mocktail_window
|
||||||
PkgConfig::UTF8PROC
|
PkgConfig::UTF8PROC
|
||||||
PRIVATE
|
PRIVATE
|
||||||
PkgConfig::FONTCONFIG
|
PkgConfig::FONTCONFIG
|
||||||
SDL3_ttf::SDL3_ttf
|
SDL3_ttf::SDL3_ttf
|
||||||
nlohmann_json::nlohmann_json
|
nlohmann_json::nlohmann_json
|
||||||
|
${CMAKE_DL_LIBS}
|
||||||
)
|
)
|
||||||
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
|
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
|
||||||
mocktail_apply_compile_options(mocktail_input_runtime)
|
mocktail_apply_compile_options(mocktail_input_runtime)
|
||||||
@@ -47,6 +50,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
|||||||
add_executable(roblox_input_router_test
|
add_executable(roblox_input_router_test
|
||||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_test.cc
|
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_test.cc
|
||||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_router_test.cc
|
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_router_test.cc
|
||||||
|
${MOCKTAIL_INPUT_ROOT}/tests/roblox_xr_controller_input_test.cc
|
||||||
)
|
)
|
||||||
target_link_libraries(roblox_input_router_test PRIVATE
|
target_link_libraries(roblox_input_router_test PRIVATE
|
||||||
Mocktail::InputRuntime
|
Mocktail::InputRuntime
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Copyright 2026 Mocktail Project Authors
|
||||||
|
# Licensed under the Apache License, Version 2.0.
|
||||||
|
|
||||||
|
include_guard(GLOBAL)
|
||||||
|
|
||||||
|
option(MOCKTAIL_ENABLE_FSR3 "Build the optional temporal upscaler SDK" OFF)
|
||||||
|
|
||||||
|
# AUTO builds OpenXR support when the third_party/OpenXR-SDK submodule is
|
||||||
|
# checked out and skips it otherwise, so source packages that do not fetch
|
||||||
|
# submodules still build. ON requires the submodule; OFF never builds it.
|
||||||
|
set(MOCKTAIL_ENABLE_VR "AUTO" CACHE STRING
|
||||||
|
"Build experimental OpenXR support: AUTO, ON or OFF")
|
||||||
|
set_property(CACHE MOCKTAIL_ENABLE_VR PROPERTY STRINGS AUTO ON OFF)
|
||||||
|
set(_mocktail_openxr_sdk "${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK/CMakeLists.txt")
|
||||||
|
string(TOUPPER "${MOCKTAIL_ENABLE_VR}" _mocktail_vr_request)
|
||||||
|
if(_mocktail_vr_request STREQUAL "AUTO")
|
||||||
|
if(EXISTS "${_mocktail_openxr_sdk}" AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||||
|
set(MOCKTAIL_ENABLE_VR ON)
|
||||||
|
else()
|
||||||
|
message(STATUS "OpenXR SDK submodule not found; building without VR "
|
||||||
|
"(git submodule update --init third_party/OpenXR-SDK to enable it)")
|
||||||
|
set(MOCKTAIL_ENABLE_VR OFF)
|
||||||
|
endif()
|
||||||
|
elseif(MOCKTAIL_ENABLE_VR)
|
||||||
|
set(MOCKTAIL_ENABLE_VR ON)
|
||||||
|
else()
|
||||||
|
set(MOCKTAIL_ENABLE_VR OFF)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(mocktail_vr STATIC
|
||||||
|
src/vr/gaze_aa.cc
|
||||||
|
src/vr/fsr_upscaler.cc
|
||||||
|
src/vr/fsr3_scene_capture.cc
|
||||||
|
src/vr/diagnostics.cc
|
||||||
|
src/vr/headset_detection.cc
|
||||||
|
src/vr/openxr_backend_mode.cc
|
||||||
|
src/vr/vr_perf.cc
|
||||||
|
src/vr/vr_mirror.cc
|
||||||
|
src/vr/xr_controller.cc
|
||||||
|
src/vr/roblox_vr_device_bridge.cc
|
||||||
|
src/vr/virtual_keyboard.cc)
|
||||||
|
add_library(Mocktail::VR ALIAS mocktail_vr)
|
||||||
|
target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include")
|
||||||
|
target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||||
|
target_link_libraries(mocktail_vr PUBLIC Vulkan::Headers PRIVATE nlohmann_json::nlohmann_json)
|
||||||
|
# shm_open for the desktop eye mirror lives in librt on glibc.
|
||||||
|
find_library(MOCKTAIL_RT_LIBRARY rt)
|
||||||
|
if(MOCKTAIL_RT_LIBRARY)
|
||||||
|
target_link_libraries(mocktail_vr PUBLIC ${MOCKTAIL_RT_LIBRARY})
|
||||||
|
endif()
|
||||||
|
mocktail_apply_compile_options(mocktail_vr)
|
||||||
|
|
||||||
|
if(MOCKTAIL_ENABLE_VR)
|
||||||
|
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||||
|
message(FATAL_ERROR "Mocktail OpenXR support currently targets Linux")
|
||||||
|
endif()
|
||||||
|
if(NOT EXISTS "${_mocktail_openxr_sdk}")
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"Initialize OpenXR: git submodule update --init third_party/OpenXR-SDK")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Scope upstream's generic option names to this dependency. Static linking
|
||||||
|
# packages the pinned loader without requiring a matching system loader.
|
||||||
|
function(mocktail_add_openxr_sdk)
|
||||||
|
set(BUILD_LOADER ON)
|
||||||
|
set(DYNAMIC_LOADER OFF)
|
||||||
|
set(BUILD_WITH_SYSTEM_JSONCPP OFF)
|
||||||
|
set(BUILD_TESTS OFF)
|
||||||
|
set(BUILD_API_LAYERS OFF)
|
||||||
|
set(BUILD_CONFORMANCE_TESTS OFF)
|
||||||
|
set(BUILD_SDK_TESTS OFF)
|
||||||
|
set(BUILD_FORCE_GENERATION OFF)
|
||||||
|
add_subdirectory("${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK"
|
||||||
|
"${CMAKE_BINARY_DIR}/third_party/OpenXR-SDK" EXCLUDE_FROM_ALL)
|
||||||
|
endfunction()
|
||||||
|
mocktail_add_openxr_sdk()
|
||||||
|
if(MOCKTAIL_ENABLE_FSR3)
|
||||||
|
add_subdirectory(third_party/fidelityfx)
|
||||||
|
target_sources(mocktail_vr PRIVATE src/vr/fsr3_upscaler.cc src/vr/fsr3_gaze_transport.cc)
|
||||||
|
target_link_libraries(mocktail_vr PRIVATE nightcap_fsr3)
|
||||||
|
target_compile_definitions(mocktail_vr PUBLIC MOCKTAIL_ENABLE_FSR3=1)
|
||||||
|
endif()
|
||||||
|
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc
|
||||||
|
src/vr/openxr_backend.cc src/vr/xr_foveation_maps.cc src/vr/gles_transport.cc src/vr/xr_actions.cc
|
||||||
|
src/vr/virtual_keyboard_raster.cc)
|
||||||
|
# The in-headset keyboard draws its labels with the same SDL3_ttf and
|
||||||
|
# fontconfig stack as the TextBox overlay.
|
||||||
|
find_package(SDL3 3.4 REQUIRED CONFIG)
|
||||||
|
find_package(SDL3_ttf REQUIRED CONFIG)
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
|
||||||
|
target_link_libraries(mocktail_vr PRIVATE SDL3_ttf::SDL3_ttf SDL3::SDL3
|
||||||
|
PkgConfig::FONTCONFIG)
|
||||||
|
# Error unwinding is confined to these native session implementations; their
|
||||||
|
# public entry points catch errors before returning to the guest runtime.
|
||||||
|
set_source_files_properties(src/vr/openxr_preview.cc src/vr/openxr_backend.cc
|
||||||
|
src/vr/xr_actions.cc src/vr/xr_foveation_maps.cc
|
||||||
|
PROPERTIES COMPILE_OPTIONS -fexceptions)
|
||||||
|
target_compile_definitions(mocktail_vr PRIVATE XR_USE_GRAPHICS_API_VULKAN)
|
||||||
|
option(MOCKTAIL_VR_DIAGNOSTICS
|
||||||
|
"Compile VR diagnostics: scripted head pose, eye evidence dumps" OFF)
|
||||||
|
if(MOCKTAIL_VR_DIAGNOSTICS)
|
||||||
|
target_compile_definitions(mocktail_vr PRIVATE MOCKTAIL_VR_DIAGNOSTICS=1)
|
||||||
|
endif()
|
||||||
|
set_source_files_properties(src/vr/openxr_backend.cc PROPERTIES COMPILE_DEFINITIONS "XR_USE_GRAPHICS_API_OPENGL_ES;XR_USE_PLATFORM_EGL")
|
||||||
|
target_include_directories(mocktail_vr PRIVATE ${MOCKTAIL_EGL_INCLUDE_DIR} ${MOCKTAIL_GLES3_INCLUDE_DIR})
|
||||||
|
target_link_libraries(mocktail_vr PRIVATE OpenXR::openxr_loader Vulkan::Headers ${CMAKE_DL_LIBS})
|
||||||
|
else()
|
||||||
|
target_sources(mocktail_vr PRIVATE src/vr/openxr_disabled.cc
|
||||||
|
src/vr/xr_actions_disabled.cc src/vr/xr_foveation_maps_disabled.cc)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(MOCKTAIL_ENABLE_VR)
|
||||||
|
add_executable(mocktail_vr_probe src/vr/probe_main.cc)
|
||||||
|
set_target_properties(mocktail_vr_probe PROPERTIES
|
||||||
|
OUTPUT_NAME mocktail-vr-probe)
|
||||||
|
target_link_libraries(mocktail_vr_probe PRIVATE Mocktail::VR)
|
||||||
|
mocktail_apply_compile_options(mocktail_vr_probe)
|
||||||
|
install(TARGETS mocktail_vr_probe
|
||||||
|
RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
|
||||||
|
endif()
|
||||||
@@ -28,7 +28,7 @@ device: pc-windows-11
|
|||||||
runtime:
|
runtime:
|
||||||
# Boolean (default: false): start without a visible SDL window.
|
# Boolean (default: false): start without a visible SDL window.
|
||||||
headless: false
|
headless: false
|
||||||
# String (optional): use a specific x86_64 libroblox.so instead of the
|
# String (optional): use a specific native-architecture libroblox.so instead of the
|
||||||
# active verified payload.
|
# active verified payload.
|
||||||
# roblox_library: /absolute/path/to/libroblox.so
|
# roblox_library: /absolute/path/to/libroblox.so
|
||||||
|
|
||||||
@@ -52,6 +52,31 @@ graphics:
|
|||||||
# unless frame_rate_limit is unlimited.
|
# unless frame_rate_limit is unlimited.
|
||||||
# vsync: off
|
# vsync: off
|
||||||
|
|
||||||
|
# vr:
|
||||||
|
# String (default: off): experimental VR through OpenXR on supported Roblox
|
||||||
|
# builds (ARM64: 2998 and 3222). off starts on the desktop; on starts in
|
||||||
|
# VR like --vr. auto starts in VR when SteamVR is already running with a connected
|
||||||
|
# headset (ALVR included), and on the desktop otherwise. auto never starts
|
||||||
|
# SteamVR. --vr and --no-vr override this for one launch. The older
|
||||||
|
# enabled: true/false still works as on/off; set one or the other.
|
||||||
|
# Standalone OpenXR test scene: mocktail-vr-probe --scene.
|
||||||
|
# mode: off
|
||||||
|
# Boolean (default: true): also show the left-eye VR view in the desktop
|
||||||
|
# window. Restart Nightcap after changing this setting. Steam Frame
|
||||||
|
# uses its Video settings Desktop Mirror control, off by default.
|
||||||
|
# desktop_mirror: true
|
||||||
|
# String (default: fxaa): smooth jagged edges in the headset. Roblox draws
|
||||||
|
# the VR eyes without anti-aliasing; fxaa filters each eye on its way to the
|
||||||
|
# headset. The Steam Frame profile uses its separate gaze AA control.
|
||||||
|
# off sends the eyes as rendered.
|
||||||
|
# anti_aliasing: fxaa
|
||||||
|
# Optional string or integer: frame-rate limit used instead of
|
||||||
|
# graphics.frame_rate_limit while VR is active. Same values: -1 (Roblox's
|
||||||
|
# own cap, usually 60), display, unlimited, or any positive integer. The
|
||||||
|
# default, unlimited, lets the OpenXR runtime pace frames at the headset's
|
||||||
|
# refresh rate.
|
||||||
|
# frame_rate_limit: unlimited
|
||||||
|
|
||||||
performance:
|
performance:
|
||||||
# Boolean (default: false): size Roblox scheduler/render queues from every
|
# Boolean (default: false): size Roblox scheduler/render queues from every
|
||||||
# physical CPU core. A place's Lua/main thread can still remain serial.
|
# physical CPU core. A place's Lua/main thread can still remain serial.
|
||||||
|
|||||||
@@ -77,7 +77,20 @@
|
|||||||
"allow_legacy_binary_patches": false,
|
"allow_legacy_binary_patches": false,
|
||||||
"allow_host_abi_bridges": true,
|
"allow_host_abi_bridges": true,
|
||||||
"allow_host_constructor_replay": true,
|
"allow_host_constructor_replay": true,
|
||||||
"reason": "AArch64 arm64-v8a payload of the same 2.736.1408 release as the verified x86-64 profile; allocator, constructor replay, and pre-JNI bootstrap RVAs were derived by cross-architecture semantic matching."
|
"reason": "AArch64 arm64-v8a payload of the same 2.736.1408 release as the verified x86-64 profile; allocator, constructor replay, and pre-JNI bootstrap RVAs were derived by cross-architecture semantic matching.",
|
||||||
|
"fmod_output_device_bridge": {
|
||||||
|
"vtable_rva": "0x632aeb0",
|
||||||
|
"string_constructor_rva": "0x1d5a6b0",
|
||||||
|
"count_method_rva": "0x2f96540",
|
||||||
|
"info_method_rva": "0x2f96600",
|
||||||
|
"current_method_rva": "0x2f965a0",
|
||||||
|
"select_method_rva": "0x2f962d4",
|
||||||
|
"input_count_method_rva": "0x2f968b4",
|
||||||
|
"input_info_method_rva": "0x2f968c4",
|
||||||
|
"input_current_method_rva": "0x2f9697c",
|
||||||
|
"input_select_method_rva": "0x2f96088",
|
||||||
|
"vtable_layout_version": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version_name": "2.736.1408",
|
"version_name": "2.736.1408",
|
||||||
@@ -102,7 +115,80 @@
|
|||||||
"input_current_method_rva": "0x32d13a6",
|
"input_current_method_rva": "0x32d13a6",
|
||||||
"input_select_method_rva": "0x32d0a56"
|
"input_select_method_rva": "0x32d0a56"
|
||||||
},
|
},
|
||||||
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled."
|
"vr_debug_device_bridge": {
|
||||||
|
"vtable_rva": "0x6c87b38",
|
||||||
|
"constructor_rva": "0x3810614",
|
||||||
|
"state_getter_rva": "0x38109f6",
|
||||||
|
"eye_getter_rva": "0x38109e6",
|
||||||
|
"eye_initializer_rva": "0x3810a18",
|
||||||
|
"emulator_flag_storage_rva": "0x7369920",
|
||||||
|
"device_create_framebuffer_vtable_offset": "0x180"
|
||||||
|
},
|
||||||
|
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled. Experimental vr_debug_device_bridge metadata (DebugDeviceVR constructor/state/eye/initializer RVAs) is machine-validated at activation and gated behind --vr; native stereo output is not yet verified end-to-end."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_host_abi_bridges": true,
|
||||||
|
"allow_host_constructor_replay": true,
|
||||||
|
"allow_legacy_binary_patches": false,
|
||||||
|
"default_allowed": true,
|
||||||
|
"elf_build_id": "5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
|
||||||
|
"fmod_output_device_bridge": {
|
||||||
|
"count_method_rva": "0x320c676",
|
||||||
|
"current_method_rva": "0x320c6c6",
|
||||||
|
"info_method_rva": "0x320c716",
|
||||||
|
"select_method_rva": "0x320bf78",
|
||||||
|
"string_constructor_rva": "0x1d7a038",
|
||||||
|
"vtable_layout_version": 2,
|
||||||
|
"vtable_rva": "0x6cd3ce0"
|
||||||
|
},
|
||||||
|
"reason": "Exact-Build-ID host ABI promoted from the validated probation profile; native stereo, VR state/pointer/haptic contracts and FMOD output-device layout v2 were re-derived for 3092. Experimental: runs when selected explicitly (--roblox-lib) but is never an update target, so non-VR users stay on the default payload.",
|
||||||
|
"status": "experimental",
|
||||||
|
"user_game_settings_fullscreen_setter_rva": "0x45c5f42",
|
||||||
|
"version_code": 3092,
|
||||||
|
"version_name": "2.738.1397",
|
||||||
|
"vr_debug_device_bridge": {
|
||||||
|
"vtable_rva": "0x6d08108",
|
||||||
|
"constructor_rva": "0x37f9fe4",
|
||||||
|
"state_getter_rva": "0x37fa3c6",
|
||||||
|
"eye_getter_rva": "0x37fa3b6",
|
||||||
|
"eye_initializer_rva": "0x37fa3e8",
|
||||||
|
"emulator_flag_storage_rva": "0x73f4690",
|
||||||
|
"device_create_framebuffer_vtable_offset": "0x188"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version_name": "2.742.1059",
|
||||||
|
"version_code": 3222,
|
||||||
|
"abi": "arm64-v8a",
|
||||||
|
"elf_build_id": "25aa7fd31008a744c3c99f717e7d16d81d371d12",
|
||||||
|
"status": "supported",
|
||||||
|
"default_allowed": true,
|
||||||
|
"allow_legacy_binary_patches": false,
|
||||||
|
"allow_host_abi_bridges": true,
|
||||||
|
"allow_host_constructor_replay": true,
|
||||||
|
"reason": "Verified on Steam Frame 2026-10-15: signed ARM64 3222 payload, exact allocator/bootstrap and reordered constructor roles, VR state/pointer/reflection/framebuffer contracts and layout-2 FMOD bridge; native startup, two distinct eye bindings at 2592x2592, real OpenXR projection and controlled experience transitions. Worn-headset quality and remote voice audibility remain unconfirmed.",
|
||||||
|
"fmod_output_device_bridge": {
|
||||||
|
"vtable_rva": "0x6569770",
|
||||||
|
"string_constructor_rva": "0x1e47180",
|
||||||
|
"count_method_rva": "0x2c35714",
|
||||||
|
"info_method_rva": "0x2c357d4",
|
||||||
|
"current_method_rva": "0x2c35774",
|
||||||
|
"select_method_rva": "0x2c34f18",
|
||||||
|
"input_count_method_rva": "0x2c35d58",
|
||||||
|
"input_info_method_rva": "0x2c35d68",
|
||||||
|
"input_current_method_rva": "0x2c361a0",
|
||||||
|
"input_select_method_rva": "0x2c34c80",
|
||||||
|
"vtable_layout_version": 2
|
||||||
|
},
|
||||||
|
"vr_debug_device_bridge": {
|
||||||
|
"vtable_rva": "0x67b6688",
|
||||||
|
"constructor_rva": "0x4e5558c",
|
||||||
|
"state_getter_rva": "0x4e55964",
|
||||||
|
"eye_getter_rva": "0x4e55958",
|
||||||
|
"eye_initializer_rva": "0x4e55974",
|
||||||
|
"emulator_flag_storage_rva": "0x6c0d1b0",
|
||||||
|
"device_create_framebuffer_vtable_offset": "0x188"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
version: 1
|
||||||
|
device: pc-windows-11
|
||||||
|
|
||||||
|
graphics:
|
||||||
|
backend: direct-vulkan
|
||||||
|
vsync: off
|
||||||
|
|
||||||
|
vr:
|
||||||
|
enabled: true
|
||||||
|
desktop_mirror: false
|
||||||
|
frame_rate_limit: unlimited
|
||||||
|
|
||||||
|
audio:
|
||||||
|
output_device: default
|
||||||
|
input_device: default
|
||||||
|
|
||||||
|
updates:
|
||||||
|
automatic: false
|
||||||
|
launch_after_update: false
|
||||||
|
source: auto
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Benchmarking
|
|
||||||
|
|
||||||
A pull request that claims a speedup shows before and after numbers taken
|
|
||||||
the way this guide describes. Numbers from different machines are not
|
|
||||||
compared with each other. Each run is compared with a run on the same
|
|
||||||
machine.
|
|
||||||
|
|
||||||
## Two layers, two tools
|
|
||||||
|
|
||||||
Roblox measures its own work. Nightcap measures the layer between Roblox and
|
|
||||||
your driver.
|
|
||||||
|
|
||||||
| Layer | What it covers | Tool |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Roblox | Scripts, physics, rendering jobs, network | Roblox's stats overlays, Shift+F4 and Shift+F5 in game |
|
|
||||||
| Nightcap | Vulkan adapter: present, swapchain and GPU waits, submits, ETC2 texture decode, pipeline and shader creation | `--profile` |
|
|
||||||
|
|
||||||
A Nightcap change should move the `--profile` numbers. If it only moves the
|
|
||||||
Roblox overlay numbers, say so in the pull request, since the cause is then
|
|
||||||
less direct.
|
|
||||||
|
|
||||||
## Recording a trace
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mocktail --profile ~/nightcap-before.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The Vulkan adapter writes a Chrome-format trace to that file, a few times a
|
|
||||||
second. The file is valid JSON after every write, so a session that crashes
|
|
||||||
or is killed still leaves a readable trace. Exit from the Roblox menu when you are done. Open the file
|
|
||||||
in [ui.perfetto.dev](https://ui.perfetto.dev) or `chrome://tracing`.
|
|
||||||
|
|
||||||
Expect about 130 bytes per event, around 15 MB per minute at 240 FPS.
|
|
||||||
Profiling is off unless `--profile` is given, and `MOCKTAIL_PROFILE_TRACE`
|
|
||||||
does the same thing as the flag.
|
|
||||||
|
|
||||||
### What the trace contains
|
|
||||||
|
|
||||||
Each slice is named after the call it times, on the thread that made it.
|
|
||||||
|
|
||||||
| Category | Slices | Arguments |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `present` | `vkQueuePresentKHR`: the whole adapter present, including the text overlay | `result` |
|
|
||||||
| `present` | `host present`: the host driver's present, nested inside the slice above | |
|
|
||||||
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
|
|
||||||
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
|
|
||||||
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
|
|
||||||
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
|
|
||||||
| `texture` | `etc2 staging create`: creating a staging block for ETC2 uploads, when no block has room left | `bytes` |
|
|
||||||
| `texture` | `etc2 staging destroy`: destroying empty staging blocks beyond the four kept idle | `buffers` |
|
|
||||||
| `texture` | `etc2 release wait`: a command buffer reset waiting for its uploads' decode. Only waits of 0.1 ms or more are recorded | `ticket` |
|
|
||||||
| `memory` | `vkAllocateMemory` | `bytes`, `type` (memory type index), `result` |
|
|
||||||
| `memory` | `vkFreeMemory`, `vkUnmapMemory`: both include waiting for ETC2 decodes that still read that memory | |
|
|
||||||
| `memory` | `vkMapMemory`, `vkCreateImage`, `vkDestroyImage` | |
|
|
||||||
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
|
|
||||||
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
|
|
||||||
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
|
|
||||||
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | `cpu_us`: CPU time the main thread used during the call. Far below the slice's duration means the step spent it waiting |
|
|
||||||
|
|
||||||
The `frame interval (ms)` counter is the time between successive adapter
|
|
||||||
presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's
|
|
||||||
own cost per frame.
|
|
||||||
|
|
||||||
Two more counters cover the same interval. `present thread cpu (ms)` is the
|
|
||||||
CPU time the thread that presents used in it, and is absent for a frame
|
|
||||||
presented from a different thread than the one before. `process cpu (ms)` is
|
|
||||||
the CPU time of every thread together, so dividing it by the frame interval
|
|
||||||
gives the number of cores busy. A long frame with little present-thread CPU
|
|
||||||
was spent waiting.
|
|
||||||
|
|
||||||
## The benchmark runs
|
|
||||||
|
|
||||||
Run every scenario before and after the change, on the same build type
|
|
||||||
(`make build`), with the same `config.yaml`. Close other GPU-heavy apps.
|
|
||||||
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
|
|
||||||
because a frame cap hides frame time gains.
|
|
||||||
|
|
||||||
Record `~/.config/gamemode.ini` too, and do not change it between the before
|
|
||||||
and after runs. `desiredgov` there decides the CPU governor for the whole
|
|
||||||
session and therefore which engine pump mode is chosen, which moves process
|
|
||||||
CPU by more than most changes under test. See
|
|
||||||
[PERFORMANCE.md](PERFORMANCE.md).
|
|
||||||
|
|
||||||
Frame interval p99 varies by 2 ms or more between identical runs, so a
|
|
||||||
difference that size is not a result. Three runs per scenario is the minimum
|
|
||||||
for any frame-time claim; slice totals for a specific subsystem are far less
|
|
||||||
noisy and can be read from fewer.
|
|
||||||
|
|
||||||
| Scenario | How to start | What to do | What it shows |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
|
|
||||||
| Tower of Hell | `mocktail --profile toh.json --launch-uri "roblox://experiences/start?placeId=1962086868"` | Stand still at spawn for 60 s, exit | Steady-state frame time on a light scene |
|
|
||||||
| Murder Mystery 2 | `mocktail --profile mm2.json --launch-uri "roblox://experiences/start?placeId=142823291"` | Stay in the lobby for 60 s, exit | Frame time with many players and a busier scene |
|
|
||||||
|
|
||||||
For the cold run, point the driver's shader cache at an empty directory, so
|
|
||||||
your normal cache is left alone:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cache=$(mktemp -d)
|
|
||||||
MESA_SHADER_CACHE_DIR="$cache" __GL_SHADER_DISK_CACHE_PATH="$cache" \
|
|
||||||
mocktail --profile home-cold.json
|
|
||||||
```
|
|
||||||
|
|
||||||
`MESA_SHADER_CACHE_DIR` applies to Mesa drivers (AMD, Intel) and
|
|
||||||
`__GL_SHADER_DISK_CACHE_PATH` to the NVIDIA driver.
|
|
||||||
|
|
||||||
Run each scenario three times and keep the median run, since one run can
|
|
||||||
catch a background stall.
|
|
||||||
|
|
||||||
## Comparing runs
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/summarize_profile_trace.py toh-before.json toh-after.json
|
|
||||||
```
|
|
||||||
|
|
||||||
This prints a Markdown table with one column per trace: frame count, frame
|
|
||||||
interval mean, p50, p95, p99 and max, then count, total, mean, p99 and max
|
|
||||||
for every slice name, largest total first. Paste the table into the pull
|
|
||||||
request and attach both traces.
|
|
||||||
|
|
||||||
For frame time, compare p95 and p99 as well as the mean. Stutter shows up in
|
|
||||||
the tail first. For first-load work, compare the `total (ms)` rows of the
|
|
||||||
`pipeline` and `texture` slices from the cold run.
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Building and source layout
|
||||||
|
|
||||||
|
The [installation guide](INSTALL.md) covers a complete source build on Steam
|
||||||
|
Frame. The container recipe uses Fedora 44 ARM64 with CMake, Ninja, SDL3,
|
||||||
|
OpenXR, Vulkan, WebKitGTK and Nightcap's other native dependencies.
|
||||||
|
The Frame build enables OpenXR and the ARM64 bridge, disables runtime
|
||||||
|
diagnostics, and uses native rendering. Fedora's `glslang` and `spirv-tools`
|
||||||
|
packages are included for shader generation and validation. The ARM64 workflow
|
||||||
|
builds the same container recipe and checks the package before export.
|
||||||
|
|
||||||
|
## Upstream source
|
||||||
|
|
||||||
|
This branch includes Nightcap's `vr` additions through
|
||||||
|
[`adccf4e4841275bce85d0330013367e2c124bd10`](https://github.com/CoderDayton/nightcap/commit/adccf4e4841275bce85d0330013367e2c124bd10),
|
||||||
|
including all changes from its `main` branch at that revision. The update adds
|
||||||
|
emoji fallback fonts for native text fields, decoded/compressed emulated-texture
|
||||||
|
byte counters, menu-only native Luau compilation, and queued game switching.
|
||||||
|
Menu compilation is enabled only after validating the native flag on ARM64;
|
||||||
|
explicit client settings remain in control.
|
||||||
|
|
||||||
|
Frame already uses the same reference SMAA revision as this update. Its existing
|
||||||
|
gaze AA path is retained without adding a second full-eye filter. Frame's native
|
||||||
|
2160 × 2160 rendering, 72 Hz profile, simple Video controls, controllers, audio
|
||||||
|
and foveation integration remain the platform defaults.
|
||||||
|
|
||||||
|
## Build scripts
|
||||||
|
|
||||||
|
`build-frame-nightcap.sh` configures a Release build at `/work/build-frame-arm64`
|
||||||
|
and installs to `/opt/nightcap-vr-arm64`. VR is enabled and runtime diagnostics
|
||||||
|
are disabled. It also builds the Steam application identity helper.
|
||||||
|
|
||||||
|
For incremental development in an existing Frame image, with Nightcap closed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --arch arm64 --name nightcap-frame-development \
|
||||||
|
--cpus 3 --memory 8g --security-opt label=disable \
|
||||||
|
-v "$PWD:/work" --entrypoint /bin/bash \
|
||||||
|
localhost/nightcap-vr-arm64:frame-foveated -lc \
|
||||||
|
'bash /work/build-frame-nightcap.sh && bash /work/test-arm64-bridge.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
The test script runs focused ARM64 bridge, pose, controller, lifecycle, JNI,
|
||||||
|
audio and host ABI tests, including the native assembly return-value fixture.
|
||||||
|
It also builds the GPU test tools. Build and test commands share one CMake tree;
|
||||||
|
run them sequentially.
|
||||||
|
|
||||||
|
## Main components
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/vr/` | OpenXR lifecycle, gaze, stereo eye transport, controllers, AA and foveation maps |
|
||||||
|
| `src/vr/roblox_vr_arm64_build_contracts.h` | Selects exact ARM64 client contracts |
|
||||||
|
| `src/vr/roblox_vr_arm64_contract*.h` | Client-specific ARM instruction and object layout contracts |
|
||||||
|
| `src/audio/` | Audio playback and microphone recording bridges |
|
||||||
|
| `src/compat/host_abi_profile.cc` | Client startup, allocator and native host ABI profiles |
|
||||||
|
| `src/graphics/vulkan_foveation_adapter.inc` | Scene rendering integration for density maps |
|
||||||
|
| `tools/steam_frame/` | Frame launcher, Steam shortcut and Video settings integration |
|
||||||
|
| `tools/frame_video_settings/` | OpenVR settings, refresh-rate and timing helpers |
|
||||||
|
| `config/roblox_compatibility.json` | Supported client identities and compatibility metadata |
|
||||||
|
| `config/steam-frame.example.yaml` | Initial Frame runtime settings |
|
||||||
|
|
||||||
|
## Client compatibility
|
||||||
|
|
||||||
|
The ARM64 bridge supports these client identities:
|
||||||
|
|
||||||
|
| Version | Build | ELF build ID |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2.742.1059 | 3222 | `25aa7fd31008a744c3c99f717e7d16d81d371d12` |
|
||||||
|
| 2.736.1408 | 2998 | `ed2b035768b1513561d0e6ea7f9bbec35b0e60e5` |
|
||||||
|
|
||||||
|
New clients can change native methods, virtual tables, object fields and startup
|
||||||
|
ordering. Add matching ARM64 contracts and host/audio profiles before enabling a
|
||||||
|
new client. Keep the existing signature and build identity checks in place.
|
||||||
|
|
||||||
|
The timing helper in `tools/frame_video_settings/frame_timings.cc` reports the
|
||||||
|
scene process, dashboard visibility, headset activity and compositor throttling
|
||||||
|
alongside frame times. Compare the same view in the same experience while the
|
||||||
|
headset is displaying projection frames. Standby or desktop-only frames do not
|
||||||
|
measure active VR throughput.
|
||||||
|
|
||||||
|
Shader sources and their generated SPIR-V header are included. Regenerating the
|
||||||
|
AA shaders with `tools/compile_gaze_aa.py` additionally needs `glslangValidator`.
|
||||||
|
The SMAA sources retain their upstream license in `src/vr/smaa/`.
|
||||||
|
|
||||||
|
## Native rendering
|
||||||
|
|
||||||
|
The shipping Frame profile uses native rendering with gaze anti-aliasing.
|
||||||
|
MSAA and upscaling controls are absent from the Frame Video settings. The build
|
||||||
|
sets `MOCKTAIL_ENABLE_FSR3=OFF`, so the FidelityFX SDK is not linked into the
|
||||||
|
Frame runtime. Reference upscaler sources and their licenses remain in the tree.
|
||||||
|
|
||||||
|
To regenerate the gaze AA shaders after changing them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/compile_gaze_aa.py .
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires `glslangValidator`. The SMAA sources retain their upstream license
|
||||||
|
in `src/vr/smaa/`.
|
||||||
|
|
||||||
|
## Foveation tile calibration
|
||||||
|
|
||||||
|
Density-map texels and GMEM render tiles have different sizes. The gaze guard
|
||||||
|
must protect every render tile touching the full-detail disk. The narrow entry
|
||||||
|
in `include/mocktail/vr/foveation_tile_bounds.h` applies only to this identity:
|
||||||
|
|
||||||
|
- Turnip driver ID 18, vendor `0x5143`, device `0x43051401`, version `109060195`.
|
||||||
|
- Driver name `turnip Mesa driver`, info `Mesa 26.3.0-devel (git-abc426bb62)`.
|
||||||
|
- Driver UUID `586b9fa4514cb88b3c11a968ef4d62e4`; measured ELF build ID
|
||||||
|
`b7e5995d0b202f06e81cd5a0f809ec1b744a0cb8`.
|
||||||
|
- A 2160 × 2160 framebuffer, one layer/subpass, single-sample
|
||||||
|
`R8G8B8A8_UNORM` color and `D32_SFLOAT` depth, plus the density attachment.
|
||||||
|
Offsets, resolves, extra attachments, multiview and feedback/input attachments
|
||||||
|
retain the conservative guard.
|
||||||
|
|
||||||
|
On that binary, `vkCmdBeginRenderPass2` at RVA `0xb12180` calls the layout
|
||||||
|
selector at `0xad67c0`. The selector initializes its layout from the FDM flag
|
||||||
|
at render-pass byte 145; subsequent checks can only select `AVOID_CCU` again.
|
||||||
|
The divisor routine at `0xa9e300` returns 1 for FDM. CPU-only creation of 72
|
||||||
|
render-pass/imageless-framebuffer combinations, covering FDM on/off and every
|
||||||
|
standard color/depth load/store combination, produced identical budgets:
|
||||||
|
356352 pixels for `FULL`, 350208 for `AVOID_CCU`. Their base tiles are
|
||||||
|
480 × 736 and 768 × 448 respectively. FDM therefore selects 768 × 448
|
||||||
|
independently of shaders and load/store operations for this tuple.
|
||||||
|
|
||||||
|
The GPU qualification compared a high-frequency full-density reference against
|
||||||
|
the production map helper with its 68 × 68 map and 768 × 448 guard. Sweep both
|
||||||
|
gaze axes through `{-1, -.75, -.4, 0, .4, .75, 1}`, radii
|
||||||
|
`{.08, .15, .22, .35}`, and all three active profiles. All 588 cases preserved
|
||||||
|
every pixel inside the gaze disk exactly, with 588 valid fragment-invocation
|
||||||
|
queries and no validation errors. Mean fragment invocations were 51.46% of the
|
||||||
|
reference in this fixture; this measures shading work, not game FPS. Requalify
|
||||||
|
both the driver invariant and this image/counter sweep before adding identities
|
||||||
|
or pass tuples. Unknown identities retain the conservative policy.
|
||||||
|
|
||||||
|
The CPU identity and coordinate contracts can be rerun with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build-frame-arm64 --target foveation_tile_bounds_test framebuffer_density_map_test
|
||||||
|
ctest --test-dir build-frame-arm64 -R 'FoveationTileBounds|FramebufferDensityMap' --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frame-time stability
|
||||||
|
|
||||||
|
The Android `mallinfo` bridge shares a real host allocator snapshot for up to
|
||||||
|
500 ms. glibc scans allocator free lists under arena locks; several consecutive
|
||||||
|
queries therefore reuse a recent coherent sample. A scan runs outside the cache
|
||||||
|
mutex, and concurrent readers can keep using the previous completed sample.
|
||||||
|
Allocation, deallocation and the process-memory limit checks retain their normal
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
Gaze density generation reuses per-eye pixel and half-float buffers across
|
||||||
|
frames. The density values and gaze protection are unchanged. At a 1:1 render and output size, the native path avoids unnecessary
|
||||||
|
reconstruction, temporal resource tracking and upscaler shader dispatches.
|
||||||
|
Gaze anti-aliasing runs on the composed eye image.
|
||||||
|
|
||||||
|
|
||||||
|
The Vulkan eye copy is submitted on the OpenXR binding queue before releasing
|
||||||
|
its swapchain images, so SteamVR can enqueue composition immediately. When the
|
||||||
|
guest device requested exactly one unprotected queue, completion is deferred
|
||||||
|
until the next use of its eye-copy and anti-aliasing resources. GPU queue ordering keeps subsequent guest
|
||||||
|
commands behind the copy. The upstream submission ring tracks command buffers, fences and source/destination
|
||||||
|
images. Presentation dependencies are consumed on the GPU and replaced with a
|
||||||
|
per-swapchain-image semaphore, so the CPU does not wait for the current render.
|
||||||
|
AA resources wait only for previous eye-copy users;
|
||||||
|
current-frame present dependencies are excluded from that resource wait.
|
||||||
|
Captured source-image handles are drained before
|
||||||
|
image destruction; queue exposure, swapchain replacement and teardown also
|
||||||
|
drain pending work before releasing resources. `xrWaitFrame` remains on the
|
||||||
|
original render thread. Additional queues, readbacks and the virtual keyboard
|
||||||
|
retain completed-copy synchronization. `MOCKTAIL_VR_EARLY_SUBMIT=0` restores
|
||||||
|
the original submission order, while `MOCKTAIL_VR_DEFER_COPY_WAIT=0` keeps the
|
||||||
|
early submission but completes its fence before waiting for the next frame.
|
||||||
|
|
||||||
|
Density-map generation skips the distance/ramp calculation inside the protected
|
||||||
|
full-detail disk and beyond the peripheral plateau. Transition cells retain the
|
||||||
|
same distance calculation and spatial pattern. This reduces CPU work without
|
||||||
|
changing gaze protection or density values.
|
||||||
|
|
||||||
|
The Frame launcher enables a render-thread CPU preference after projection
|
||||||
|
submission starts. It reads Linux CPU capacity values only for the thread's
|
||||||
|
existing allowed CPUs and selects capacity peers within 80% of their maximum.
|
||||||
|
Incomplete capacity information or fewer than two preferred CPUs retains the
|
||||||
|
original allocation. This keeps render work on the application's performance
|
||||||
|
cores while respecting SteamVR's CPU reservation. Other engine threads retain
|
||||||
|
their normal allocation.
|
||||||
|
|
||||||
|
## Upstream integration
|
||||||
|
|
||||||
|
The imported Nightcap VR revision is recorded in [the credits](../THIRD_PARTY.md).
|
||||||
|
The Frame launcher disables the generic full-eye FXAA pass so Steam Video's
|
||||||
|
Anti Aliasing setting remains the single AA control. The upstream JNI haptic
|
||||||
|
protocol runs natively on ARM64. Its optional x86 SetMotor instruction scanner
|
||||||
|
is gated to x86; ARM64 build IDs, instruction checks and native return-value
|
||||||
|
bridges retain their own contracts.
|
||||||
|
|
||||||
|
Steam Video settings are loaded once at startup and then refreshed on a worker.
|
||||||
|
The rendering callbacks copy a completed snapshot; they never read the settings
|
||||||
|
file or parse JSON. Unchanged content reuses its parsed values. A failed refresh
|
||||||
|
keeps the last completed snapshot, and shutdown wakes the polling worker.
|
||||||
|
The launcher gives a configured per-app headset refresh rate precedence over
|
||||||
|
the desktop rate observed before the app gains focus, and derives Roblox's
|
||||||
|
startup FPS cap from that selected profile.
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
# Install on Steam Frame
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Steam Frame running its ARM64 Linux desktop, with Steam and SteamVR installed.
|
||||||
|
- Native SteamVR at `/opt/steamvr`, including `steamxr_linuxarm64.json`.
|
||||||
|
- The Frame's Turnip/Freedreno Vulkan driver under `/usr/lib` and its Vulkan ICD
|
||||||
|
under `/usr/share/vulkan/icd.d`.
|
||||||
|
- Rootless Podman, Git, Python 3 and a C++17 compiler on the Frame.
|
||||||
|
- `zstd` for importing the compressed ARM64 release archive.
|
||||||
|
- A Linux filesystem for the checkout, internet access and space for the source,
|
||||||
|
Fedora build image and a separate Roblox client download.
|
||||||
|
|
||||||
|
The runtime uses Fedora 44 ARM64 in a rootless container. Keep the Frame's
|
||||||
|
own graphics drivers: the launcher mounts them into the container. Building
|
||||||
|
from source also needs a case-sensitive Linux checkout because some Android
|
||||||
|
headers differ only by letter case. The source and ARM64 build are public.
|
||||||
|
|
||||||
|
## 1. Get the source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --branch steam-frame --recurse-submodules \
|
||||||
|
https://github.com/InstanceBrick/nightcap.git \
|
||||||
|
"$HOME/.local/share/nightcap-steam-frame-src"
|
||||||
|
cd "$HOME/.local/share/nightcap-steam-frame-src"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Install the ARM64 image
|
||||||
|
|
||||||
|
Download `nightcap-steam-frame-arm64.oci.tar.zst` and its `.sha256` file from
|
||||||
|
[Releases](https://github.com/InstanceBrick/nightcap/releases). From the
|
||||||
|
directory containing both files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
sha256sum -c nightcap-steam-frame-arm64.oci.tar.zst.sha256
|
||||||
|
zstd -dc nightcap-steam-frame-arm64.oci.tar.zst | podman load
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive imports as `localhost/nightcap-vr-arm64:frame-foveated`. It contains
|
||||||
|
the ARM64 Nightcap runtime and its Fedora dependencies, with no Roblox client,
|
||||||
|
personal data, SteamVR installation or proprietary graphics drivers.
|
||||||
|
|
||||||
|
To build the same image from source instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build --arch arm64 --jobs 1 \
|
||||||
|
-f packaging/steam-frame/Containerfile \
|
||||||
|
-t localhost/nightcap-vr-arm64:frame-foveated .
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds Nightcap and its helpers from source. It does not download or bundle
|
||||||
|
Roblox. The image tag is also the one used by the Frame launcher.
|
||||||
|
|
||||||
|
## 3. Install the launcher and refresh-rate helper
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/steam_frame/install_launcher.py
|
||||||
|
mkdir -p "$HOME/.local/share/nightcap-vr-arm64/data/tools"
|
||||||
|
c++ -std=c++17 -O2 tools/frame_video_settings/refresh_rate.cc -ldl \
|
||||||
|
-o "$HOME/.local/share/nightcap-vr-arm64/data/tools/refresh_rate"
|
||||||
|
```
|
||||||
|
|
||||||
|
The application menu entry is **Nightcap Roblox VR**.
|
||||||
|
|
||||||
|
## 4. Download the compatible Roblox client
|
||||||
|
|
||||||
|
Close Nightcap first. Prepare its data directory and copy the initial settings
|
||||||
|
only if no configuration exists yet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
frame_data="$HOME/.local/share/nightcap-vr-arm64/data"
|
||||||
|
mkdir -p "$frame_data/.config/mocktail"
|
||||||
|
if [ ! -f "$frame_data/.config/mocktail/config.yaml" ]; then
|
||||||
|
cp config/steam-frame.example.yaml "$frame_data/.config/mocktail/config.yaml"
|
||||||
|
fi
|
||||||
|
podman run --rm --arch arm64 --userns=keep-id \
|
||||||
|
--security-opt label=disable \
|
||||||
|
-v "$frame_data:/data:rw" \
|
||||||
|
-e HOME=/data -e XDG_CONFIG_HOME=/data/.config \
|
||||||
|
-e XDG_DATA_HOME=/data/.local/share -e XDG_CACHE_HOME=/data/.cache \
|
||||||
|
--entrypoint /opt/nightcap-vr-arm64/lib64/mocktail/mocktail_updater \
|
||||||
|
localhost/nightcap-vr-arm64:frame-foveated \
|
||||||
|
update --no-latest-check --skip-canary
|
||||||
|
```
|
||||||
|
|
||||||
|
The updater downloads the newest ARM64 client supported by the included catalog.
|
||||||
|
The command retains APK signature, architecture, build identity and payload
|
||||||
|
integrity checks. `--skip-canary` skips the separate desktop graphics test; VR
|
||||||
|
starts through the Frame launcher in the next step. Client files stay in your
|
||||||
|
local data directory.
|
||||||
|
|
||||||
|
## 5. Launch in VR and add to Steam
|
||||||
|
|
||||||
|
Start SteamVR and connect the headset, then open **Nightcap Roblox VR** from the
|
||||||
|
application menu. Sign in through Roblox's normal interface.
|
||||||
|
|
||||||
|
To add the Steam shortcut automatically, enable Steam's local browser debugging
|
||||||
|
endpoint at `127.0.0.1:8080`, keep Steam running and use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/steam_frame/register_steam_shortcut.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script creates or reuses the launcher shortcut, sets its Nightcap icon and
|
||||||
|
marks it as a VR application. It saves the generated identity locally. If the
|
||||||
|
endpoint is unavailable, use Steam's **Add a Non-Steam Game** dialog and select
|
||||||
|
`~/.local/bin/nightcap-vr-arm64`, then mark the shortcut as a VR application.
|
||||||
|
|
||||||
|
## 6. Add the SteamVR Video controls
|
||||||
|
|
||||||
|
The settings integration supports a specific SteamVR dashboard revision. It
|
||||||
|
checks the installed dashboard before applying changes and leaves other
|
||||||
|
revisions untouched. Valve dashboard files are read from your local installation.
|
||||||
|
|
||||||
|
With SteamVR closed and the original dashboard files visible:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p frame-ui-local/originals frame-ui-local/generated
|
||||||
|
cp /opt/steamvr/resources/webinterface/dashboard/chunk~8012d0c89.js \
|
||||||
|
frame-ui-local/originals/steamvr-dashboard.original.js
|
||||||
|
cp /opt/steamvr/resources/webinterface/dashboard/systemui.html \
|
||||||
|
frame-ui-local/originals/steamvr-systemui.original.html
|
||||||
|
cp /opt/steamvr/resources/webinterface/dashboard/settings_desktop.html \
|
||||||
|
frame-ui-local/originals/steamvr-settings_desktop.original.html
|
||||||
|
python3 tools/steam_frame/prepare_video_ui.py \
|
||||||
|
frame-ui-local/originals frame-ui-local/generated
|
||||||
|
sudo python3 tools/steam_frame/install_video_ui.py frame-ui-local/generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Start SteamVR again. The settings are described in [Video settings](SETTINGS.md).
|
||||||
|
The service mounts three generated files read-only; the original SteamVR files
|
||||||
|
are preserved. If upgrading an existing installation, reuse your saved original
|
||||||
|
files instead of copying the already patched dashboard.
|
||||||
|
|
||||||
|
## Update Nightcap
|
||||||
|
|
||||||
|
Close Nightcap before replacing its image or launcher. Pull the `steam-frame`
|
||||||
|
branch, then load the new ARM64 release archive as in step 2 and rerun the
|
||||||
|
launcher installer. Existing Roblox downloads, sign-in state and settings stay
|
||||||
|
in the separate data directory.
|
||||||
|
|
||||||
|
For source builds, pull this branch and rebuild under a temporary image tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
git submodule update --init --recursive
|
||||||
|
podman build --arch arm64 --jobs 1 \
|
||||||
|
-f packaging/steam-frame/Containerfile \
|
||||||
|
-t localhost/nightcap-vr-arm64:next .
|
||||||
|
podman tag localhost/nightcap-vr-arm64:frame-foveated \
|
||||||
|
localhost/nightcap-vr-arm64:previous
|
||||||
|
podman tag localhost/nightcap-vr-arm64:next \
|
||||||
|
localhost/nightcap-vr-arm64:frame-foveated
|
||||||
|
python3 tools/steam_frame/install_launcher.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing Roblox downloads, sign-in state and settings remain in the data
|
||||||
|
directory. To install a newly supported Roblox client, repeat step 4 after
|
||||||
|
updating Nightcap. Automatic client updates are off in the supplied Frame
|
||||||
|
configuration, since the ARM64 VR bridge needs matching client interfaces.
|
||||||
|
|
||||||
|
To restore the previous Nightcap image with the app closed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman tag localhost/nightcap-vr-arm64:previous \
|
||||||
|
localhost/nightcap-vr-arm64:frame-foveated
|
||||||
|
```
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Performance and CPU load
|
|
||||||
|
|
||||||
What Nightcap does to your CPU and GPU, which knobs change it, and how to
|
|
||||||
measure rather than guess.
|
|
||||||
|
|
||||||
## GameMode core pinning
|
|
||||||
|
|
||||||
Nightcap asks Feral GameMode for a performance session at startup
|
|
||||||
(`performance.gamemode`, default `auto`). GameMode raises the CPU governor and
|
|
||||||
the process priority, which helps.
|
|
||||||
|
|
||||||
On a CPU with a wide spread of per-core maximum frequencies — Intel P-core and
|
|
||||||
E-core parts, AMD parts with preferred cores — GameMode also pins the process
|
|
||||||
to the fastest cores. On a 32-thread host that can mean 4 usable threads out of
|
|
||||||
32. Roblox spreads its work across a worker pool, so the pin concentrates that
|
|
||||||
pool onto a couple of physical cores instead of spreading it. Those cores sit
|
|
||||||
near their boost ceiling and run hot while the rest of the chip idles.
|
|
||||||
|
|
||||||
GameMode re-applies the pin roughly every 5 seconds, so widening the mask from
|
|
||||||
inside the process does not hold. Releasing the request does release the pin.
|
|
||||||
|
|
||||||
Under `auto`, Nightcap therefore checks its CPU affinity immediately after the
|
|
||||||
request. If GameMode narrowed it, Nightcap ends the request and prints:
|
|
||||||
|
|
||||||
```
|
|
||||||
[gamemode] released: it pinned this process to a subset of the CPUs, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
The trade is that GameMode's governor and priority boost go with it. To keep
|
|
||||||
those and lose only the pinning, turn pinning off for every game on the host.
|
|
||||||
|
|
||||||
These are the settings to run with:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# ~/.config/gamemode.ini
|
|
||||||
[general]
|
|
||||||
desiredgov=performance
|
|
||||||
|
|
||||||
[cpu]
|
|
||||||
pin_cores=no
|
|
||||||
park_cores=no
|
|
||||||
```
|
|
||||||
|
|
||||||
`desiredgov` is the one to check first. The packaged default in
|
|
||||||
`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
|
|
||||||
your own file replaces it, and `desiredgov=powersave` there means GameMode
|
|
||||||
clocks the CPU **down** for the whole session while still reporting
|
|
||||||
`[gamemode] performance request active`. The symptoms are a low CPU clock
|
|
||||||
under load, a GPU that never leaves its lower SM clocks, and the engine pump
|
|
||||||
falling back to `tpause` — which costs about 70% of a core instead of 3%,
|
|
||||||
because `sleep` is only chosen when the governor reads `performance`. On one
|
|
||||||
13900K host, correcting `powersave` to `performance` took the process from
|
|
||||||
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
|
|
||||||
|
|
||||||
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
|
|
||||||
order. It does not read anything under Nightcap's own config directory, so this
|
|
||||||
file has to live in one of those three places. The daemon reads it when it
|
|
||||||
starts, so restart it after editing: `systemctl --user restart gamemoded`.
|
|
||||||
|
|
||||||
Restarting the daemon drops the session of any game already running; relaunch
|
|
||||||
the game for new settings to apply to it.
|
|
||||||
|
|
||||||
`gamemoded` is D-Bus activated through
|
|
||||||
`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
|
|
||||||
does not hold — the next client request starts it again within seconds. To
|
|
||||||
measure with GameMode genuinely off, mask it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl --user mask gamemoded # and --unmask afterwards
|
|
||||||
```
|
|
||||||
|
|
||||||
The governor switch runs through polkit. Debian and Ubuntu only allow it for
|
|
||||||
members of the `gamemode` group; without that, the daemon's journal shows
|
|
||||||
`cpugovctl set performance: Not authorized` and the governor stays put while
|
|
||||||
the session is otherwise active. Add yourself and log in again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG gamemode "$USER"
|
|
||||||
```
|
|
||||||
|
|
||||||
Fedora and Arch allow any logged-in local user.
|
|
||||||
|
|
||||||
Setting `performance.gamemode: on` accepts the pinning instead: `on` is an
|
|
||||||
explicit request for GameMode, and Nightcap keeps the session whatever it does
|
|
||||||
to the affinity. `off` never contacts the daemon.
|
|
||||||
|
|
||||||
To see which CPUs the process may use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pid=$(pgrep -x Main)
|
|
||||||
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
|
|
||||||
```
|
|
||||||
|
|
||||||
Two traps here. The engine process reports its name as `Main`, not
|
|
||||||
`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
|
|
||||||
CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
|
|
||||||
process, so a single call reports the main thread's mask and says nothing
|
|
||||||
about the workers — which is where the game's load actually is.
|
|
||||||
|
|
||||||
For the same reason, `ps -L -o psr=` shows which core each thread is running
|
|
||||||
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
|
|
||||||
preferring P-cores for runnable work is normal and is not a mask.
|
|
||||||
|
|
||||||
## The main-thread pump
|
|
||||||
|
|
||||||
Roblox posts its per-frame main-thread step at a time of its own choosing,
|
|
||||||
and nothing signals the host when it does. The host main thread therefore
|
|
||||||
polls `nativeCallMessagesFromMainThread`, and a post that waits more than a
|
|
||||||
fraction of a millisecond costs a whole frame. Between polls the thread rests
|
|
||||||
for 100 µs, and how it rests decides the CPU clock:
|
|
||||||
|
|
||||||
| Mode | When | What it costs |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `sleep` | The governor is `performance`, which GameMode sets | The thread idles at about 3% of a core. The governor holds the clock. |
|
|
||||||
| `tpause` | Otherwise, on a CPU with WAITPKG (Intel 12th gen and newer) | The core stays awake in a light C0 state. Frame rate holds; the OS books the time as busy, so the thread shows around 70% of a core, at lower power than a spin. |
|
|
||||||
| `spin` | Otherwise | The core stays awake at full power. |
|
|
||||||
|
|
||||||
Under a power-saving governor the CPU clocks a bursty core down unless some
|
|
||||||
core stays awake, and Roblox's render thread then runs slower and drops
|
|
||||||
frames. That is why `sleep` is not the default there. Startup prints the
|
|
||||||
chosen mode:
|
|
||||||
|
|
||||||
```
|
|
||||||
[main] engine pump rest: tpause (MOCKTAIL_ENGINE_PUMP_REST=sleep|tpause|spin)
|
|
||||||
```
|
|
||||||
|
|
||||||
`MOCKTAIL_ENGINE_PUMP_REST` forces a mode. `sleep` without the governor's help
|
|
||||||
runs cooler for a few frames per second less; measure before keeping it.
|
|
||||||
|
|
||||||
## Small texture upscaling
|
|
||||||
|
|
||||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) redraws ETC2 textures of 64px or
|
|
||||||
smaller at N times their width and height. At the default that is 16 times the
|
|
||||||
pixels per affected texture.
|
|
||||||
|
|
||||||
The cost lands in two places. Decoding and resampling is CPU work on up to 8
|
|
||||||
threads while textures load, which shows up as a burst rather than a steady
|
|
||||||
load. The enlarged textures then stay in GPU memory for the session and cost
|
|
||||||
extra sampling bandwidth on every frame that uses them.
|
|
||||||
|
|
||||||
That decode does not run on the thread that submits the frame. On a device
|
|
||||||
with a timeline semaphore, uploads decode on a background dispatcher and the
|
|
||||||
submit carries a semaphore wait, so the GPU waits for the texture instead of
|
|
||||||
the render thread. In the trace this is the `etc2 decode` slice on its own
|
|
||||||
thread, with only a negligible `etc2 gather` left on the submitting one. A
|
|
||||||
device without that feature decodes inline as before.
|
|
||||||
|
|
||||||
Set it to `1` to turn upscaling off:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frame rate and present mode
|
|
||||||
|
|
||||||
`graphics.frame_rate_limit` (default `-1`) leaves Roblox's own in-game
|
|
||||||
framerate cap in charge; Nightcap sets no `DFIntTaskSchedulerTargetFps`
|
|
||||||
override. A fixed value forwards that number to the scheduler.
|
|
||||||
|
|
||||||
`graphics.vsync` (default `auto`) selects the presentation mode. `auto` and
|
|
||||||
`on` both pick the lowest-latency synchronized mode the driver offers, in
|
|
||||||
order: `FIFO_LATEST_READY`, `MAILBOX`, `FIFO_RELAXED`, `FIFO`. The first two do
|
|
||||||
not block the render thread on the display refresh, so the frame rate is
|
|
||||||
governed by the in-game cap rather than by presentation. `off` selects
|
|
||||||
`IMMEDIATE` and does not synchronize at all.
|
|
||||||
|
|
||||||
If the frame rate is higher than your refresh rate, the extra frames are work
|
|
||||||
you cannot see. Lower the in-game cap to your refresh rate first.
|
|
||||||
|
|
||||||
## Measuring
|
|
||||||
|
|
||||||
Shift+F4 in game shows Roblox's own scheduler jobs, with a millisecond cost and
|
|
||||||
a percentage for each. It does not show Nightcap's threads — the Vulkan
|
|
||||||
adapter, the ETC2 decode workers, the libc shim — so it is a reading on the
|
|
||||||
game, not on the process.
|
|
||||||
|
|
||||||
For the whole process:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
top -H -p $(pgrep -x mocktail) # per-thread CPU, sorted
|
|
||||||
ps -L -o psr=,pcpu=,comm= -p $(pgrep -x mocktail) # which core each thread is on
|
|
||||||
nvidia-smi --query-gpu=utilization.gpu,temperature.gpu,power.draw --format=csv
|
|
||||||
```
|
|
||||||
|
|
||||||
A thread pinned near 100% while the frame rate is steady is a busy-wait. The
|
|
||||||
one exception is the host main thread in `tpause` mode, which the OS reports
|
|
||||||
as busy while the core is parked (see above). Load spread across many
|
|
||||||
`RBX Worker` threads is the game's own scheduler, and its cost tracks the
|
|
||||||
place you are in — part count, moving parts and joints all show in the
|
|
||||||
Shift+F4 World section.
|
|
||||||
|
|
||||||
`--profile <file>` writes a Chrome trace of the Vulkan adapter's work for
|
|
||||||
ui.perfetto.dev. See [BENCHMARKING.md](BENCHMARKING.md).
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Nightcap roadmap
|
|
||||||
|
|
||||||
What Nightcap is working toward, in priority order. Items move to
|
|
||||||
[Shipped](#shipped) when they land on `main`. Open an issue to propose a
|
|
||||||
change to this list.
|
|
||||||
|
|
||||||
## 1. Settings UI
|
|
||||||
|
|
||||||
Nightcap is configured by editing `~/.config/mocktail/config.yaml`. Most
|
|
||||||
people never open it, so they never find the FPS unlock or the vsync switch.
|
|
||||||
|
|
||||||
Goal: a small GTK4 settings window, reachable from the app menu and from a
|
|
||||||
`--settings` flag, that reads and writes the same `config.yaml`.
|
|
||||||
|
|
||||||
- Graphics tab: backend, frame rate limit, vsync, small texture upscale.
|
|
||||||
- Input tab: the `input.raw_mouse` toggle and controller options.
|
|
||||||
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
|
|
||||||
- Discord tab: every field in the `discord_rpc` block, so a friend can see
|
|
||||||
what you are playing and you can switch it all off in one click.
|
|
||||||
- Changes apply on next launch. A restart button restarts Nightcap.
|
|
||||||
|
|
||||||
Done when every option in `config/mocktail.example.yaml` that a player would
|
|
||||||
touch can be set without a text editor.
|
|
||||||
|
|
||||||
## Shipped
|
|
||||||
|
|
||||||
- Lower input latency: frames are presented as soon as they are ready.
|
|
||||||
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
|
|
||||||
releases its held buttons and sticks instead of leaving them stuck.
|
|
||||||
- Mouse look keeps responding after a long turn, instead of going dead until
|
|
||||||
the pointer travels back from past the screen edge.
|
|
||||||
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
|
|
||||||
deltas in host units, so fractional display scaling no longer changes
|
|
||||||
first-person sensitivity.
|
|
||||||
- Small textures upscaled 4x by default, with PNG overrides in
|
|
||||||
`~/.config/mocktail/textures`.
|
|
||||||
- ETC2 textures decoded on desktop GPUs without stutter.
|
|
||||||
- No more error 319 kicks after joining a server.
|
|
||||||
- Roblox sees the real host RAM and screen size.
|
|
||||||
- Desktop launcher that works on Wayland and finds the installed binary.
|
|
||||||
- Discord Rich Presence on by default, with the experience name, creator,
|
|
||||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
|
||||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
|
||||||
- Custom Discord presence: title, details, state, and large and small icons
|
|
||||||
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
|
|
||||||
`{creator_name}` placeholders.
|
|
||||||
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
|
|
||||||
including shader and pipeline creation, readable in `ui.perfetto.dev`.
|
|
||||||
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
|
|
||||||
`scripts/summarize_profile_trace.py` builds the before and after table.
|
|
||||||
- Texture uploads no longer stall the frame they land on: ETC2 decode and
|
|
||||||
resampling run on a persistent worker pool.
|
|
||||||
- Less stutter while joining an experience and while textures load in. See
|
|
||||||
the [changelog](../CHANGELOG.md) for the numbers.
|
|
||||||
- The host main thread rests between engine polls instead of spinning, and
|
|
||||||
GameMode's core pinning is released, so Roblox's workers spread across
|
|
||||||
every core. See [PERFORMANCE.md](PERFORMANCE.md).
|
|
||||||
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
|
||||||
upstream.
|
|
||||||
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
|
|
||||||
repo on GitHub Pages.
|
|
||||||
|
|
||||||
## Not planned
|
|
||||||
|
|
||||||
- Replacing the `mocktail` binary and config names. Keeping them makes
|
|
||||||
upstream merges clean.
|
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Settings
|
||||||
|
|
||||||
|
## Video
|
||||||
|
|
||||||
|
Open the game's SteamVR **Video** settings after installing the settings
|
||||||
|
integration.
|
||||||
|
|
||||||
|
The standard profile is **native 2160 × 2160 per eye**, **72 Hz**, **SMAA High**
|
||||||
|
and **Performance** foveated rendering. The application targets 72 FPS at 72 Hz;
|
||||||
|
actual frame rate depends on the experience and Roblox's graphics settings.
|
||||||
|
The gaze radius remains adjustable, and existing saved choices are preserved.
|
||||||
|
|
||||||
|
| Setting | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| Foveated Rendering | Off uses full-density shading throughout. Quality, Balanced and Performance progressively reduce peripheral shading work. Each mode keeps the same full-detail gaze region. |
|
||||||
|
| Gaze sharp radius | Sets the minimum full-detail region around your gaze, from 8% to 35%. Larger values preserve more of the view at full density and cost more GPU time. Hardware tile boundaries can enlarge this region. |
|
||||||
|
| Anti Aliasing | Off, FXAA, SMAA Low or SMAA High. Filtering runs around your gaze on the composed eye image, including interface edges. SMAA High is the default. |
|
||||||
|
| Desktop Mirror | Off by default on Frame. On displays the VR image in the desktop window as well as the headset. Relaunch Nightcap after changing it. |
|
||||||
|
| Performance Overlay | Shows actual game FPS, headset Hz, average frame time, SteamVR GPU time and per-eye resolution at the bottom center of the headset view. It hides while the Steam menu is open and does not capture controller input. |
|
||||||
|
| Resolution Per Eye | Sets the headset eye-buffer size. Nightcap's recommended default is 2160 × 2160 per eye; Auto and Reset to Defaults use this baseline. Explicit custom values stay selected. Changes apply after the slider settles, with a brief pause while eye buffers are rebuilt at a safe frame boundary. |
|
||||||
|
| Display Refresh Rate | The standard profile uses 72 Hz. The launcher follows the selected per-game headset rate and any explicit Steam frame limit. Restart Nightcap after changing the rate or frame limit so Roblox's internal cap matches it. |
|
||||||
|
|
||||||
|
Enable **Performance Overlay**, then close the Steam menu. **APP FPS** counts
|
||||||
|
new Roblox projections over a rolling second. **HMD Hz** is the headset refresh
|
||||||
|
rate. **FRAME** is average time per submitted game frame, **TARGET** is the
|
||||||
|
configured application frame rate, and **BUDGET** is the time available per
|
||||||
|
application frame: 13.89 ms for 72 FPS at 72 Hz. Repeated or reprojected
|
||||||
|
compositor frames do not count as new game frames.
|
||||||
|
|
||||||
|
**GPU** averages SteamVR timings for new application frames, excluding
|
||||||
|
compositor-only reprojections. It can include scheduling gaps. The overlay
|
||||||
|
reports standby or missing frames instead of retaining a stale FPS. Its setting
|
||||||
|
persists between launches and can be turned off at any time.
|
||||||
|
|
||||||
|
Foveated rendering reduces fragment-shader work in the 3D scene before the
|
||||||
|
image is composed. The final composition stays at native resolution, keeping
|
||||||
|
screen-space text and interface elements from being coarsened a second time.
|
||||||
|
Your gaze region retains the detail available at the selected eye resolution;
|
||||||
|
the periphery uses progressively lower shading density.
|
||||||
|
|
||||||
|
Foveation, gaze radius and anti-aliasing controls update while the application
|
||||||
|
is running. Higher eye resolutions improve fine detail and increase GPU and
|
||||||
|
memory cost. For more headroom, keep Performance foveation, reduce the gaze
|
||||||
|
radius if needed, or lower Roblox's own graphics quality.
|
||||||
|
|
||||||
|
The launcher uses the selected Steam Video resolution without an extra
|
||||||
|
multiplier. To add supersampling, set `NIGHTCAP_VR_RENDER_SCALE` when launching
|
||||||
|
Nightcap; for example, `NIGHTCAP_VR_RENDER_SCALE=1.25`. The default is `1.0`.
|
||||||
|
Larger values increase GPU work.
|
||||||
|
|
||||||
|
The launcher reads the selected headset refresh rate and SteamVR's frame limit
|
||||||
|
on every launch. At 72 Hz with no frame limit, Roblox's internal scheduler is
|
||||||
|
capped at 72 FPS. Half-rate rendering is not forced automatically.
|
||||||
|
|
||||||
|
On Frame, the render thread prefers higher-capacity cores within the CPU
|
||||||
|
allocation supplied by SteamVR. To use the default thread placement, launch with
|
||||||
|
`NIGHTCAP_VR_PREFER_PERFORMANCE_CORES=0`.
|
||||||
|
|
||||||
|
## Controllers and typing
|
||||||
|
|
||||||
|
The native VR bridge supplies controller tracking, buttons, axes and haptics.
|
||||||
|
Roblox experiences choose how to use those inputs. The in-headset keyboard opens
|
||||||
|
when a text field receives focus; point at a key and pull the trigger to type.
|
||||||
|
Steam Controller desktop navigation can also be configured through the Steam
|
||||||
|
shortcut's controller layout.
|
||||||
|
|
||||||
|
## Microphone
|
||||||
|
|
||||||
|
Set the Frame's headset microphone as the default input in the desktop audio
|
||||||
|
settings and check that it is not muted. Roblox voice chat uses its normal
|
||||||
|
account eligibility, microphone permission and in-experience controls.
|
||||||
|
|
||||||
|
Nightcap's native recording bridge captures the selected input when Roblox
|
||||||
|
requests recording. To choose a different input, close Nightcap and edit
|
||||||
|
`~/.local/share/nightcap-vr-arm64/data/.config/mocktail/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
input_device: default
|
||||||
|
```
|
||||||
|
|
||||||
|
An exact SDL device name can replace `default`. Input device names appear in the
|
||||||
|
launch log.
|
||||||
|
|
||||||
|
## Desktop mirror
|
||||||
|
|
||||||
|
Open SteamVR's per-game **Video** settings and set **Desktop Mirror** to **On**
|
||||||
|
to show the VR view in a desktop window. Set it to **Off** to keep the Frame's
|
||||||
|
single headset application without the extra desktop view. Relaunch Nightcap
|
||||||
|
after changing this setting. The Frame default is **Off**.
|
||||||
|
|
||||||
|
The Frame launcher reads the saved SteamVR mirror choice and coordinates both
|
||||||
|
the mirror renderer and desktop-window visibility. This setting takes precedence
|
||||||
|
over the YAML value when using the Frame launcher. Generic Nightcap launches
|
||||||
|
can configure the mirror beside the VR option:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vr:
|
||||||
|
enabled: true
|
||||||
|
desktop_mirror: true
|
||||||
|
```
|
||||||
|
|
||||||
|
`MOCKTAIL_VR_DESKTOP_MIRROR=1` or `0` overrides the YAML setting for generic
|
||||||
|
launches. VR and desktop mirroring are separate options; enabling VR does not
|
||||||
|
require the desktop mirror.
|
||||||
|
|
||||||
|
Roblox's desktop viewport stays at 1920 × 1080 during VR for stable in-game
|
||||||
|
menu size, independently of headset eye resolution. On Frame, desktop window geometry
|
||||||
|
is not restored or saved in VR, including when the mirror is enabled.
|
||||||
|
|
||||||
|
## Files and troubleshooting
|
||||||
|
|
||||||
|
| Item | Location |
|
||||||
|
| --- | --- |
|
||||||
|
| Launcher | `~/.local/bin/nightcap-vr-arm64` |
|
||||||
|
| Launch log | `~/.local/share/nightcap-vr-arm64/last-launch.log` |
|
||||||
|
| Configuration | `~/.local/share/nightcap-vr-arm64/data/.config/mocktail/config.yaml` |
|
||||||
|
| Local client store | `~/.local/share/nightcap-vr-arm64/data/.local/share/mocktail` |
|
||||||
|
| Steam shortcut identity | `~/.local/share/nightcap-vr-arm64/steam-shortcut.json` |
|
||||||
|
|
||||||
|
If launching fails, check the launch log, make sure SteamVR is running and confirm
|
||||||
|
that the `localhost/nightcap-vr-arm64:frame-foveated` image exists. If the game
|
||||||
|
reports an unsupported client, update Nightcap before replacing the client.
|
||||||
|
Keep account data and client downloads outside source control.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "compat/bionic_host_libc_runtime.h"
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace mocktail::compat {
|
||||||
|
// Heap accounting is a sampled statistic. A glibc scan walks free lists while
|
||||||
|
// holding arena locks, so back-to-back Android queries share a recent sample.
|
||||||
|
// The scan runs outside this mutex; concurrent callers can use the last complete
|
||||||
|
// sample without waiting for another allocator walk.
|
||||||
|
class AllocatorSnapshotCache {
|
||||||
|
public:
|
||||||
|
static constexpr std::uint64_t kRefreshNs=500'000'000ULL;
|
||||||
|
template<class Reader>
|
||||||
|
BionicMallinfoSnapshot Read(std::uint64_t now,Reader reader) noexcept {
|
||||||
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
|
if(refreshing_ || (initialized_ && now<refresh_after_))return snapshot_;
|
||||||
|
refreshing_=true;
|
||||||
|
lock.unlock();
|
||||||
|
const auto fresh=reader();
|
||||||
|
lock.lock();
|
||||||
|
snapshot_=fresh;
|
||||||
|
initialized_=true;
|
||||||
|
refresh_after_=now+kRefreshNs;
|
||||||
|
refreshing_=false;
|
||||||
|
return snapshot_;
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
std::mutex mutex_;
|
||||||
|
BionicMallinfoSnapshot snapshot_{};
|
||||||
|
bool initialized_=false,refreshing_=false;
|
||||||
|
std::uint64_t refresh_after_=0;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,6 +53,19 @@ struct FmodOutputDeviceBridgeProfile {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Exact-build metadata for the experimental native VR bridge. The bridge
|
||||||
|
// interposes DebugDeviceVR slots and a checked world-pointer trampoline.
|
||||||
|
// All RVAs are validated against machine-code contracts before first use.
|
||||||
|
struct VrDebugDeviceBridgeProfile {
|
||||||
|
std::uintptr_t vtable_rva = 0;
|
||||||
|
std::uintptr_t constructor_rva = 0;
|
||||||
|
std::uintptr_t state_getter_rva = 0;
|
||||||
|
std::uintptr_t eye_getter_rva = 0;
|
||||||
|
std::uintptr_t eye_initializer_rva = 0;
|
||||||
|
std::uintptr_t emulator_flag_storage_rva = 0;
|
||||||
|
std::uintptr_t device_create_framebuffer_vtable_offset = 0;
|
||||||
|
};
|
||||||
|
|
||||||
struct BuildProfile {
|
struct BuildProfile {
|
||||||
std::string version_name;
|
std::string version_name;
|
||||||
int version_code = 0;
|
int version_code = 0;
|
||||||
@@ -70,6 +83,8 @@ struct BuildProfile {
|
|||||||
// output-device virtual slots and restores them during controlled teardown;
|
// output-device virtual slots and restores them during controlled teardown;
|
||||||
// executable guest code is never modified.
|
// executable guest code is never modified.
|
||||||
std::optional<FmodOutputDeviceBridgeProfile> fmod_output_device_bridge;
|
std::optional<FmodOutputDeviceBridgeProfile> fmod_output_device_bridge;
|
||||||
|
// Optional exact-build experimental VR capability; see the struct above.
|
||||||
|
std::optional<VrDebugDeviceBridgeProfile> vr_debug_device_bridge;
|
||||||
std::string reason;
|
std::string reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ class RobloxOutputDeviceBridge final {
|
|||||||
std::string_view logical_name,
|
std::string_view logical_name,
|
||||||
std::uintptr_t image_base);
|
std::uintptr_t image_base);
|
||||||
static int GetOutputDeviceCount(void* self);
|
static int GetOutputDeviceCount(void* self);
|
||||||
static void* GetOutputDeviceInfo(void* result, void* self, int index);
|
// A trivial aggregate lets the compiler implement the guest's platform
|
||||||
|
// sret convention (x8 on AArch64, hidden first argument on x86-64).
|
||||||
|
struct alignas(8) DeviceInfoResult { std::uint8_t bytes[56]{}; };
|
||||||
|
static DeviceInfoResult GetOutputDeviceInfo(void* self, int index);
|
||||||
static int GetCurrentOutputDevice(void* self);
|
static int GetCurrentOutputDevice(void* self);
|
||||||
static void SetCurrentOutputDevice(void* self, int index);
|
static void SetCurrentOutputDevice(void* self, int index);
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ class RobloxOutputDeviceBridge final {
|
|||||||
Status PatchVtableLocked();
|
Status PatchVtableLocked();
|
||||||
bool RestoreVtableLocked();
|
bool RestoreVtableLocked();
|
||||||
static int GetInputDeviceCount(void *self);
|
static int GetInputDeviceCount(void *self);
|
||||||
static void *GetInputDeviceInfo(void *result, void *self, int index);
|
static DeviceInfoResult GetInputDeviceInfo(void* self, int index);
|
||||||
static int GetCurrentInputDevice(void *self);
|
static int GetCurrentInputDevice(void *self);
|
||||||
static void SetCurrentInputDevice(void *self, int index);
|
static void SetCurrentInputDevice(void *self, int index);
|
||||||
int DeviceCount(bool input = false);
|
int DeviceCount(bool input = false);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
namespace mocktail::graphics::internal {
|
||||||
|
|
||||||
|
// Only transfers between two concrete queue families can be converted for a
|
||||||
|
// desktop swapchain. External/foreign ownership must reach the driver intact.
|
||||||
|
inline bool NeedsDesktopQueueNormalization(std::uint32_t source,
|
||||||
|
std::uint32_t destination) {
|
||||||
|
return source != destination && source < VK_QUEUE_FAMILY_FOREIGN_EXT &&
|
||||||
|
destination < VK_QUEUE_FAMILY_FOREIGN_EXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <class Barrier>
|
||||||
|
inline bool HasDesktopQueueTransfers(std::uint32_t count,
|
||||||
|
const Barrier* barriers) {
|
||||||
|
if (!barriers) return false;
|
||||||
|
for (std::uint32_t i = 0; i < count; ++i)
|
||||||
|
if (NeedsDesktopQueueNormalization(barriers[i].srcQueueFamilyIndex,
|
||||||
|
barriers[i].dstQueueFamilyIndex))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the caller's array for the common no-ownership-transfer path. A batch
|
||||||
|
// callback can inspect a mixed array under one backend lock; the original
|
||||||
|
// array and every structure chained through pNext remain untouched.
|
||||||
|
template <class Barrier, class Normalize, class Forward>
|
||||||
|
inline void WithDesktopQueueBarriers(std::uint32_t count,
|
||||||
|
const Barrier* barriers,
|
||||||
|
Normalize&& normalize, Forward&& forward) {
|
||||||
|
if (!HasDesktopQueueTransfers(count, barriers)) {
|
||||||
|
forward(barriers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
constexpr std::size_t kStackCount = 8;
|
||||||
|
if (count <= kStackCount) {
|
||||||
|
std::array<Barrier, kStackCount> adjusted;
|
||||||
|
std::copy_n(barriers, count, adjusted.data());
|
||||||
|
normalize(count, adjusted.data());
|
||||||
|
forward(adjusted.data());
|
||||||
|
} else {
|
||||||
|
std::vector<Barrier> adjusted(barriers, barriers + count);
|
||||||
|
normalize(count, adjusted.data());
|
||||||
|
forward(adjusted.data());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::graphics::internal
|
||||||
@@ -30,6 +30,18 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent,
|
|||||||
std::uint32_t layer_count, VkDeviceSize* compressed,
|
std::uint32_t layer_count, VkDeviceSize* compressed,
|
||||||
VkDeviceSize* decoded);
|
VkDeviceSize* decoded);
|
||||||
|
|
||||||
|
// Sums every mip level and layer; `scale` multiplies the host image's
|
||||||
|
// level-0 width and height only.
|
||||||
|
bool Etc2ImageByteCounts(EtcFormat format, const VkExtent3D& extent,
|
||||||
|
std::uint32_t mip_levels, std::uint32_t layers,
|
||||||
|
std::uint32_t scale, VkDeviceSize* compressed,
|
||||||
|
VkDeviceSize* decoded);
|
||||||
|
|
||||||
|
struct EmulatedTextureBytes {
|
||||||
|
VkDeviceSize compressed = 0;
|
||||||
|
VkDeviceSize decoded = 0;
|
||||||
|
};
|
||||||
|
|
||||||
// Whether emulated ETC2 is reported to the application as a supported
|
// Whether emulated ETC2 is reported to the application as a supported
|
||||||
// feature, from the MOCKTAIL_ADVERTISE_ETC2 value: "0" hides it, anything
|
// feature, from the MOCKTAIL_ADVERTISE_ETC2 value: "0" hides it, anything
|
||||||
// else (or unset) reports it. Hidden ETC2 still decodes ETC2 images the
|
// else (or unset) reports it. Hidden ETC2 still decodes ETC2 images the
|
||||||
@@ -152,6 +164,10 @@ class VulkanEtc2Emulation final {
|
|||||||
// pending, after waiting for any decode still writing into them.
|
// pending, after waiting for any decode still writing into them.
|
||||||
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
|
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
|
||||||
|
|
||||||
|
// Traced as the "emulated texture (MB)" and "compressed texture (MB)"
|
||||||
|
// counters.
|
||||||
|
EmulatedTextureBytes TextureBytes() const;
|
||||||
|
|
||||||
struct State;
|
struct State;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
namespace mocktail::graphics::internal {
|
||||||
|
|
||||||
|
struct FoveationDevicePolicy {
|
||||||
|
bool density_offsets_enabled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture before invoking a runtime/loader device callback: its temporary
|
||||||
|
// feature-chain records must never be inspected after the call returns.
|
||||||
|
inline FoveationDevicePolicy SnapshotFoveationDevicePolicy(
|
||||||
|
const VkDeviceCreateInfo* info) {
|
||||||
|
if (!info || info->sType != VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO ||
|
||||||
|
info->enabledExtensionCount > 4096 ||
|
||||||
|
(info->enabledExtensionCount && !info->ppEnabledExtensionNames)) return {};
|
||||||
|
bool offsets = false;
|
||||||
|
for (std::uint32_t i = 0; i < info->enabledExtensionCount; ++i) {
|
||||||
|
const char* name = info->ppEnabledExtensionNames[i];
|
||||||
|
if (!name) return {};
|
||||||
|
if (!std::strcmp(name, "VK_QCOM_fragment_density_map_offset") ||
|
||||||
|
!std::strcmp(name, "VK_EXT_fragment_density_map_offset"))
|
||||||
|
offsets = true;
|
||||||
|
}
|
||||||
|
std::array<const VkBaseInStructure*, 64> seen{};
|
||||||
|
std::size_t count = 0;
|
||||||
|
for (auto* node = static_cast<const VkBaseInStructure*>(info->pNext);
|
||||||
|
node; node = node->pNext) {
|
||||||
|
if (count == seen.size()) return {};
|
||||||
|
for (std::size_t i = 0; i < count; ++i) if (seen[i] == node) return {};
|
||||||
|
seen[count++] = node;
|
||||||
|
if (node->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_DENSITY_MAP_OFFSET_FEATURES_EXT)
|
||||||
|
offsets |= reinterpret_cast<const VkPhysicalDeviceFragmentDensityMapOffsetFeaturesEXT*>(node)
|
||||||
|
->fragmentDensityMapOffset != VK_FALSE;
|
||||||
|
}
|
||||||
|
return {offsets};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::graphics::internal
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mocktail::vr::internal {
|
||||||
|
// Retired multisample choices use the native profile's SMAA High default.
|
||||||
|
constexpr int NativeAntiAliasingMode(std::int64_t value) {
|
||||||
|
return value >= 0 && value <= 3 ? static_cast<int>(value) : 3;
|
||||||
|
}
|
||||||
|
} // namespace mocktail::vr::internal
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
namespace mocktail::vr::internal {
|
||||||
|
|
||||||
|
// Load once during startup, then keep file I/O and parsing on a worker. The
|
||||||
|
// render thread only copies a completed snapshot under a short-held lock.
|
||||||
|
template <typename T>
|
||||||
|
class AsyncSettings {
|
||||||
|
public:
|
||||||
|
using Reader = std::function<T(const T&)>;
|
||||||
|
AsyncSettings(T initial, Reader reader, std::chrono::milliseconds interval)
|
||||||
|
: value_(initial), reader_(std::move(reader)), interval_(interval) {
|
||||||
|
static_assert(std::is_trivially_copyable_v<T>);
|
||||||
|
Refresh();
|
||||||
|
worker_ = std::thread([this] {
|
||||||
|
std::unique_lock<std::mutex> stop_lock(stop_mutex_);
|
||||||
|
while (!stopped_) {
|
||||||
|
if (wake_.wait_for(stop_lock, interval_, [this] { return stopped_; }))
|
||||||
|
break;
|
||||||
|
stop_lock.unlock();
|
||||||
|
Refresh();
|
||||||
|
stop_lock.lock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
~AsyncSettings() {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(stop_mutex_);
|
||||||
|
stopped_ = true;
|
||||||
|
}
|
||||||
|
wake_.notify_one();
|
||||||
|
if (worker_.joinable()) worker_.join();
|
||||||
|
}
|
||||||
|
AsyncSettings(const AsyncSettings&) = delete;
|
||||||
|
AsyncSettings& operator=(const AsyncSettings&) = delete;
|
||||||
|
|
||||||
|
T Get() const {
|
||||||
|
std::lock_guard<std::mutex> lock(value_mutex_);
|
||||||
|
return value_;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void Refresh() {
|
||||||
|
try {
|
||||||
|
const T next = reader_(Get());
|
||||||
|
std::lock_guard<std::mutex> lock(value_mutex_);
|
||||||
|
value_ = next;
|
||||||
|
} catch (...) {
|
||||||
|
// A failed read retains the last completed settings. In particular,
|
||||||
|
// parsing must never throw through a guest rendering callback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutable std::mutex value_mutex_;
|
||||||
|
T value_;
|
||||||
|
Reader reader_;
|
||||||
|
std::chrono::milliseconds interval_;
|
||||||
|
std::mutex stop_mutex_;
|
||||||
|
std::condition_variable wake_;
|
||||||
|
bool stopped_ = false;
|
||||||
|
std::thread worker_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr::internal
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_DESKTOP_MIRROR_H_
|
||||||
|
#define MOCKTAIL_VR_DESKTOP_MIRROR_H_
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
namespace mocktail::vr::internal {
|
||||||
|
|
||||||
|
struct DesktopMirrorDispatch {
|
||||||
|
PFN_vkCmdPipelineBarrier barrier;
|
||||||
|
PFN_vkCmdClearColorImage clear;
|
||||||
|
PFN_vkCmdBlitImage blit;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record the whole copy, including layout restoration, into the submission's
|
||||||
|
// command buffer. The caller owns synchronization, begin/end and submission.
|
||||||
|
inline void RecordDesktopMirrorCommands(
|
||||||
|
VkCommandBuffer command, VkImage source, VkImageLayout source_layout,
|
||||||
|
VkExtent2D source_extent, VkImage destination, VkExtent2D destination_extent,
|
||||||
|
const DesktopMirrorDispatch& vk) {
|
||||||
|
VkImageMemoryBarrier barriers[2]{};
|
||||||
|
for (auto& barrier : barriers) {
|
||||||
|
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
|
||||||
|
barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex =
|
||||||
|
VK_QUEUE_FAMILY_IGNORED;
|
||||||
|
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||||
|
barrier.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_MEMORY_READ_BIT;
|
||||||
|
}
|
||||||
|
barriers[0].image = source;
|
||||||
|
barriers[0].oldLayout = source_layout;
|
||||||
|
barriers[0].newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
|
||||||
|
barriers[0].dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
|
||||||
|
barriers[1].image = destination;
|
||||||
|
barriers[1].oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
|
||||||
|
barriers[1].newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||||
|
barriers[1].dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||||
|
vk.barrier(command, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
|
||||||
|
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 2,
|
||||||
|
barriers);
|
||||||
|
const VkClearColorValue black{{0.f, 0.f, 0.f, 1.f}};
|
||||||
|
vk.clear(command, destination, barriers[1].newLayout, &black, 1,
|
||||||
|
&barriers[1].subresourceRange);
|
||||||
|
VkMemoryBarrier clear_barrier{};
|
||||||
|
clear_barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
|
||||||
|
clear_barrier.srcAccessMask = clear_barrier.dstAccessMask =
|
||||||
|
VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||||
|
vk.barrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||||
|
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &clear_barrier, 0, nullptr, 0,
|
||||||
|
nullptr);
|
||||||
|
const double scale =
|
||||||
|
std::min(double(destination_extent.width) / source_extent.width,
|
||||||
|
double(destination_extent.height) / source_extent.height);
|
||||||
|
const int width = std::max(1, int(source_extent.width * scale));
|
||||||
|
const int height = std::max(1, int(source_extent.height * scale));
|
||||||
|
const int x = (int(destination_extent.width) - width) / 2;
|
||||||
|
const int y = (int(destination_extent.height) - height) / 2;
|
||||||
|
VkImageBlit region{};
|
||||||
|
region.srcSubresource =
|
||||||
|
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||||
|
region.srcOffsets[1] = {static_cast<int>(source_extent.width),
|
||||||
|
static_cast<int>(source_extent.height), 1};
|
||||||
|
region.dstOffsets[0] = {x, y, 0};
|
||||||
|
region.dstOffsets[1] = {x + width, y + height, 1};
|
||||||
|
vk.blit(command, source, barriers[0].newLayout, destination,
|
||||||
|
barriers[1].newLayout, 1, ®ion, VK_FILTER_NEAREST);
|
||||||
|
for (auto& barrier : barriers) {
|
||||||
|
std::swap(barrier.oldLayout, barrier.newLayout);
|
||||||
|
barrier.srcAccessMask = barrier.dstAccessMask;
|
||||||
|
barrier.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT;
|
||||||
|
}
|
||||||
|
vk.barrier(command, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
|
||||||
|
VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 2,
|
||||||
|
barriers);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::vr::internal
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_DESKTOP_MIRROR_H_
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_EYE_RESIZE_BOUNDARY_H_
|
||||||
|
#define MOCKTAIL_VR_EYE_RESIZE_BOUNDARY_H_
|
||||||
|
|
||||||
|
namespace mocktail::vr::internal {
|
||||||
|
// Eye getters return borrowed render targets. Replacing those targets after
|
||||||
|
// either eye has been requested can invalidate work recorded earlier in the
|
||||||
|
// same frame, even if all previously submitted GPU work has finished.
|
||||||
|
class EyeResizeBoundary {
|
||||||
|
public:
|
||||||
|
void OnPresent() { presented_ = true; eyes_used_ = false; }
|
||||||
|
void OnEyeGetter() { eyes_used_ = true; }
|
||||||
|
bool CanResize() const { return presented_ && !eyes_used_; }
|
||||||
|
private:
|
||||||
|
bool presented_ = false;
|
||||||
|
bool eyes_used_ = false;
|
||||||
|
};
|
||||||
|
} // namespace mocktail::vr::internal
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mocktail::vr::internal {
|
||||||
|
struct QuantizedGazeCenter {unsigned x=0,y=0;};
|
||||||
|
inline QuantizedGazeCenter QuantizeGazeCenter(unsigned width,unsigned height,float x,float y) {
|
||||||
|
if(!width || !height || !std::isfinite(x) || !std::isfinite(y))return {};
|
||||||
|
return {unsigned(std::round((std::clamp(x,-1.f,1.f)+1.f)*.5f*width*4.f)),
|
||||||
|
unsigned(std::round((1.f-std::clamp(y,-1.f,1.f))*.5f*height*4.f))};
|
||||||
|
}
|
||||||
|
inline float ClampGazeRadius(float radius) {
|
||||||
|
return std::isfinite(radius)?std::clamp(radius,.08f,.35f):.22f;
|
||||||
|
}
|
||||||
|
inline bool ProjectGazeNdc(const float* gaze,const float* eye,const float* fov,float* x,float* y) {
|
||||||
|
if(!gaze || !eye || !fov || !x || !y)return false;
|
||||||
|
auto rotate=[](const float* q,const float* v,float* out) {
|
||||||
|
float length=0;for(int i=0;i<4;++i){if(!std::isfinite(q[i]))return false;length+=q[i]*q[i];}
|
||||||
|
if(length<1e-8f)return false;
|
||||||
|
const float inverse=1.f/std::sqrt(length);
|
||||||
|
const float a=q[0]*inverse,b=q[1]*inverse,c=q[2]*inverse,d=q[3]*inverse;
|
||||||
|
const float tx=2.f*(b*v[2]-c*v[1]),ty=2.f*(c*v[0]-a*v[2]),tz=2.f*(a*v[1]-b*v[0]);
|
||||||
|
out[0]=v[0]+d*tx+b*tz-c*ty;out[1]=v[1]+d*ty+c*tx-a*tz;out[2]=v[2]+d*tz+a*ty-b*tx;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const float forward[3]={0,0,-1};float local[3],world[3];
|
||||||
|
const float inverse_eye[4]={-eye[0],-eye[1],-eye[2],eye[3]};
|
||||||
|
if(!rotate(gaze,forward,world) || !rotate(inverse_eye,world,local) || local[2]>=-1e-5f)return false;
|
||||||
|
const float left=std::tan(fov[0]),right=std::tan(fov[1]),up=std::tan(fov[2]),down=std::tan(fov[3]);
|
||||||
|
if(!std::isfinite(left+right+up+down) || right<=left || up<=down)return false;
|
||||||
|
*x=2.f*(local[0]/-local[2]-left)/(right-left)-1.f;
|
||||||
|
*y=2.f*(local[1]/-local[2]-down)/(up-down)-1.f;
|
||||||
|
return std::isfinite(*x)&&std::isfinite(*y);
|
||||||
|
}
|
||||||
|
// OpenXR eye-foveation centers use NDC (+Y up). Vulkan framebuffer/image rows
|
||||||
|
// run downwards. Preserve a generous full-density disk around the actual gaze;
|
||||||
|
// reduce shading only outside it. Density maps store reciprocal fragment size.
|
||||||
|
inline void FillGazeDensityMap(std::vector<std::uint8_t>& pixels,unsigned width,unsigned height,
|
||||||
|
float ndc_x,float ndc_y,int level=2,float radius=.22f,
|
||||||
|
float region_half_width=0.f,float region_half_height=0.f) {
|
||||||
|
pixels.assign(std::size_t(width)*height*2,255);
|
||||||
|
if(!width || !height || !std::isfinite(ndc_x) || !std::isfinite(ndc_y) || level<=0)return;
|
||||||
|
// Quarter-cell quantization avoids uploading new maps for tiny gaze jitter.
|
||||||
|
// Its maximum position error is well inside the cell-diagonal guard below.
|
||||||
|
const auto center=QuantizeGazeCenter(width,height,ndc_x,ndc_y);
|
||||||
|
const float cx=center.x/(width*4.f),cy=center.y/(height*4.f);
|
||||||
|
const float inner=ClampGazeRadius(radius);
|
||||||
|
// Performance reduces detail sooner outside the same protected focus disk.
|
||||||
|
const float outer=inner+(level==1?.36f:level==2?.26f:.10f);
|
||||||
|
const float peripheral_work=level==1?.25f:.0625f;
|
||||||
|
// A scalar ramp is quantized by the GPU into a few hard rings. Mix adjacent
|
||||||
|
// supported fragment areas over a stable spatial pattern instead. This gives
|
||||||
|
// intermediate average shading densities without temporal random noise or a
|
||||||
|
// second full-resolution render. Alternate the elongated fragment direction
|
||||||
|
// so intermediate stages do not consistently favour horizontal edges.
|
||||||
|
constexpr float work[]={1.f,.5f,.25f,.125f,.0625f};
|
||||||
|
constexpr unsigned sizes[][2]={{1,1},{1,2},{2,2},{2,4},{4,4}};
|
||||||
|
constexpr unsigned threshold[4][4]={{0,8,2,10},{12,4,14,6},{3,11,1,9},{15,7,13,5}};
|
||||||
|
// Each map cell covers an area, not a point. Extend the full-detail region
|
||||||
|
// by a cell diagonal so a gaze near a cell boundary cannot land in a coarse
|
||||||
|
// fragment just outside the mathematical disk.
|
||||||
|
const float margin=std::hypot(1.f/width,1.f/height);
|
||||||
|
const float inner_squared=(inner+margin)*(inner+margin);
|
||||||
|
const float outer_squared=(outer+margin)*(outer+margin);
|
||||||
|
const auto peripheral_density=static_cast<std::uint8_t>(level==1?127:63);
|
||||||
|
for(unsigned y=0;y<height;++y)for(unsigned x=0;x<width;++x) {
|
||||||
|
// A tiled GPU samples density at its region centre and applies it to the
|
||||||
|
// entire region. Protect every region that could intersect the focus, not
|
||||||
|
// just the tiny map cell containing the gaze. The bound comes from the
|
||||||
|
// device's maxFragmentDensityTexelSize in framebuffer pixels.
|
||||||
|
const float dx=std::max(0.f,std::abs((x+.5f)/width-cx)-region_half_width);
|
||||||
|
const float dy=std::max(0.f,std::abs((y+.5f)/height-cy)-region_half_height);
|
||||||
|
// Flat regions need no square root or density-stage search. The output
|
||||||
|
// was initialized to full detail above; the peripheral reciprocal is exact.
|
||||||
|
// Keep the existing hypot/ramp calculation throughout the transition.
|
||||||
|
const float squared=dx*dx+dy*dy;
|
||||||
|
if(squared<=inner_squared)continue;
|
||||||
|
if(squared>=outer_squared) {
|
||||||
|
const auto index=(std::size_t(y)*width+x)*2;
|
||||||
|
pixels[index]=pixels[index+1]=peripheral_density;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float distance=std::hypot(dx,dy);
|
||||||
|
float t=std::clamp((distance-inner-margin)/(outer-inner),0.f,1.f);
|
||||||
|
t=t*t*(3.f-2.f*t);
|
||||||
|
const float target=1.f-t*(1.f-peripheral_work);
|
||||||
|
unsigned stage=0;
|
||||||
|
while(stage<4 && target<work[stage+1])++stage;
|
||||||
|
if(stage<4) {
|
||||||
|
const float blend=(work[stage]-target)/(work[stage]-work[stage+1]);
|
||||||
|
if(blend>(threshold[y%4][x%4]+.5f)/16.f)++stage;
|
||||||
|
}
|
||||||
|
const unsigned flip=(x+y)&1u;
|
||||||
|
const auto index=(std::size_t(y)*width+x)*2;
|
||||||
|
// Round density down: 128/255 requests an area smaller than 2 pixels,
|
||||||
|
// which a conforming driver may clamp to 1; 127/255 actually permits 2.
|
||||||
|
pixels[index]=static_cast<std::uint8_t>(255u/sizes[stage][flip]);
|
||||||
|
pixels[index+1]=static_cast<std::uint8_t>(255u/sizes[stage][1u-flip]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inline std::vector<std::uint8_t> GazeDensityMap(unsigned width,unsigned height,
|
||||||
|
float ndc_x,float ndc_y,int level=2,float radius=.22f,
|
||||||
|
float region_half_width=0.f,float region_half_height=0.f) {
|
||||||
|
std::vector<std::uint8_t> pixels;
|
||||||
|
FillGazeDensityMap(pixels,width,height,ndc_x,ndc_y,level,radius,region_half_width,region_half_height);
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FramebufferDensityLayout {
|
||||||
|
unsigned map_width=0,map_height=0,width=0,height=0;
|
||||||
|
unsigned texel_width=0,texel_height=0,active_width=0,active_height=0;
|
||||||
|
bool valid=false;
|
||||||
|
};
|
||||||
|
// Turnip samples at pixel>>ceil(log2(framebuffer/map)), clamped to the
|
||||||
|
// advertised power-of-two texel limits. Allocation padding is not scene UV.
|
||||||
|
inline FramebufferDensityLayout TurnipDensityLayout(unsigned map_width,unsigned map_height,
|
||||||
|
unsigned width,unsigned height,unsigned min_width,unsigned min_height,
|
||||||
|
unsigned max_width,unsigned max_height) {
|
||||||
|
FramebufferDensityLayout result{map_width,map_height,width,height};
|
||||||
|
const auto power=[](unsigned value){return value && !(value&(value-1));};
|
||||||
|
if(!map_width || !map_height || !width || !height || !power(min_width) ||
|
||||||
|
!power(min_height) || !power(max_width) || !power(max_height) ||
|
||||||
|
min_width>max_width || min_height>max_height)return result;
|
||||||
|
const auto step=[](unsigned pixels,unsigned cells,unsigned minimum,unsigned maximum) {
|
||||||
|
unsigned value=minimum;
|
||||||
|
while(value<maximum && value<pixels/cells)value*=2;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
result.texel_width=step(width,map_width,min_width,max_width);
|
||||||
|
result.texel_height=step(height,map_height,min_height,max_height);
|
||||||
|
result.active_width=1+(width-1)/result.texel_width;
|
||||||
|
result.active_height=1+(height-1)/result.texel_height;
|
||||||
|
result.valid=result.active_width<=map_width && result.active_height<=map_height;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
inline QuantizedGazeCenter QuantizeFramebufferGaze(const FramebufferDensityLayout& layout,
|
||||||
|
float x,float y) {
|
||||||
|
if(!layout.valid || !std::isfinite(x) || !std::isfinite(y))return {};
|
||||||
|
return {unsigned(std::round((std::clamp(x,-1.f,1.f)+1.f)*.5f*layout.width*4.f/layout.texel_width)),
|
||||||
|
unsigned(std::round((1.f-std::clamp(y,-1.f,1.f))*.5f*layout.height*4.f/layout.texel_height))};
|
||||||
|
}
|
||||||
|
struct GazeDensityMapKey {
|
||||||
|
unsigned x=0,y=0,width=0,height=0,map_width=0,map_height=0,texel_width=0,texel_height=0;
|
||||||
|
int level=0;float radius=0;
|
||||||
|
unsigned guard_width=0,guard_height=0,color_format=0,depth_format=0,samples=0,attachments=0;
|
||||||
|
bool operator==(const GazeDensityMapKey& other) const {
|
||||||
|
return x==other.x && y==other.y && width==other.width && height==other.height &&
|
||||||
|
map_width==other.map_width && map_height==other.map_height &&
|
||||||
|
texel_width==other.texel_width && texel_height==other.texel_height &&
|
||||||
|
level==other.level && radius==other.radius &&
|
||||||
|
guard_width==other.guard_width && guard_height==other.guard_height &&
|
||||||
|
color_format==other.color_format && depth_format==other.depth_format &&
|
||||||
|
samples==other.samples && attachments==other.attachments;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
enum class GazeDensityCacheAction { kUpload,kReuse,kNeutral };
|
||||||
|
inline GazeDensityCacheAction DensityCacheAction(bool initialized,bool cached,
|
||||||
|
std::uint64_t uploaded_frame,std::uint64_t frame,const GazeDensityMapKey& previous,
|
||||||
|
const GazeDensityMapKey& requested) {
|
||||||
|
if(initialized && cached && previous==requested)return GazeDensityCacheAction::kReuse;
|
||||||
|
// A map already referenced in this frame cannot safely be overwritten for
|
||||||
|
// another extent. Its pass uses the neutral map until the next frame.
|
||||||
|
if(initialized && uploaded_frame==frame)return GazeDensityCacheAction::kNeutral;
|
||||||
|
return GazeDensityCacheAction::kUpload;
|
||||||
|
}
|
||||||
|
inline bool FillFramebufferGazeDensityMap(std::vector<std::uint8_t>& pixels,
|
||||||
|
const FramebufferDensityLayout& layout,float ndc_x,float ndc_y,int level,float radius,
|
||||||
|
float region_half_width_pixels,float region_half_height_pixels) {
|
||||||
|
pixels.assign(std::size_t(layout.map_width)*layout.map_height*2,255);
|
||||||
|
if(!layout.valid || !std::isfinite(ndc_x) || !std::isfinite(ndc_y) ||
|
||||||
|
!std::isfinite(region_half_width_pixels) || !std::isfinite(region_half_height_pixels) ||
|
||||||
|
region_half_width_pixels<0 || region_half_height_pixels<0)return false;
|
||||||
|
if(level<=0)return true;
|
||||||
|
const auto center=QuantizeFramebufferGaze(layout,ndc_x,ndc_y);
|
||||||
|
const float cx=float(center.x)*layout.texel_width/(4.f*layout.width);
|
||||||
|
const float cy=float(center.y)*layout.texel_height/(4.f*layout.height);
|
||||||
|
const float guard_x=region_half_width_pixels/layout.width;
|
||||||
|
const float guard_y=region_half_height_pixels/layout.height;
|
||||||
|
const float inner=ClampGazeRadius(radius);
|
||||||
|
const float outer=inner+(level==1?.36f:level==2?.26f:.10f);
|
||||||
|
const float margin=std::hypot(float(layout.texel_width)/layout.width,
|
||||||
|
float(layout.texel_height)/layout.height);
|
||||||
|
const float inner_squared=(inner+margin)*(inner+margin);
|
||||||
|
const float outer_squared=(outer+margin)*(outer+margin);
|
||||||
|
const float peripheral_work=level==1?.25f:.0625f;
|
||||||
|
const auto peripheral_density=static_cast<std::uint8_t>(level==1?127:63);
|
||||||
|
constexpr float work[]={1.f,.5f,.25f,.125f,.0625f};
|
||||||
|
constexpr unsigned sizes[][2]={{1,1},{1,2},{2,2},{2,4},{4,4}};
|
||||||
|
constexpr unsigned threshold[4][4]={{0,8,2,10},{12,4,14,6},{3,11,1,9},{15,7,13,5}};
|
||||||
|
for(unsigned y=0;y<layout.active_height;++y)for(unsigned x=0;x<layout.active_width;++x) {
|
||||||
|
const float px=(x+.5f)*layout.texel_width/layout.width;
|
||||||
|
const float py=(y+.5f)*layout.texel_height/layout.height;
|
||||||
|
const float dx=std::max(0.f,std::abs(px-cx)-guard_x);
|
||||||
|
const float dy=std::max(0.f,std::abs(py-cy)-guard_y);
|
||||||
|
const float squared=dx*dx+dy*dy;
|
||||||
|
if(squared<=inner_squared)continue;
|
||||||
|
const auto index=(std::size_t(y)*layout.map_width+x)*2;
|
||||||
|
if(squared>=outer_squared) {pixels[index]=pixels[index+1]=peripheral_density;continue;}
|
||||||
|
float t=std::clamp((std::hypot(dx,dy)-inner-margin)/(outer-inner),0.f,1.f);
|
||||||
|
t=t*t*(3.f-2.f*t);
|
||||||
|
const float target=1.f-t*(1.f-peripheral_work);
|
||||||
|
unsigned stage=0;
|
||||||
|
while(stage<4 && target<work[stage+1])++stage;
|
||||||
|
if(stage<4 && (work[stage]-target)/(work[stage]-work[stage+1])>
|
||||||
|
(threshold[y%4][x%4]+.5f)/16.f)++stage;
|
||||||
|
const unsigned flip=(x+y)&1u;
|
||||||
|
pixels[index]=static_cast<std::uint8_t>(255u/sizes[stage][flip]);
|
||||||
|
pixels[index+1]=static_cast<std::uint8_t>(255u/sizes[stage][1u-flip]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// The driver advertises R16G16_SFLOAT density attachments. These exact binary
|
||||||
|
// reciprocals avoid UNORM rounding and cover every area generated above.
|
||||||
|
inline void FillHalfDensityMap(std::vector<std::uint16_t>& result,const std::vector<std::uint8_t>& pixels) {
|
||||||
|
result.resize(pixels.size());
|
||||||
|
for(std::size_t i=0;i<pixels.size();++i)
|
||||||
|
result[i]=pixels[i]==127?0x3800u:pixels[i]==63?0x3400u:0x3c00u;
|
||||||
|
}
|
||||||
|
inline std::vector<std::uint16_t> HalfDensityMap(const std::vector<std::uint8_t>& pixels) {
|
||||||
|
std::vector<std::uint16_t> result;
|
||||||
|
FillHalfDensityMap(result,pixels);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
inline bool IsFoveationSceneSampleCount(VkSampleCountFlagBits samples) {
|
||||||
|
return samples == VK_SAMPLE_COUNT_1_BIT || samples == VK_SAMPLE_COUNT_2_BIT ||
|
||||||
|
samples == VK_SAMPLE_COUNT_4_BIT || samples == VK_SAMPLE_COUNT_8_BIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the native MSAA attachments and resolve. Only the extra density map
|
||||||
|
// is single-sampled; changing the scene images or pipeline sample count here
|
||||||
|
// would invalidate native framebuffers and memory allocations.
|
||||||
|
inline bool IsFoveationScenePassCompatible(const VkRenderPassCreateInfo& info) {
|
||||||
|
if (info.pNext || info.flags || info.subpassCount != 1 || !info.pSubpasses ||
|
||||||
|
!info.pAttachments) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto& subpass = info.pSubpasses[0];
|
||||||
|
if (subpass.flags || subpass.pipelineBindPoint != VK_PIPELINE_BIND_POINT_GRAPHICS ||
|
||||||
|
subpass.colorAttachmentCount != 1 || !subpass.pColorAttachments ||
|
||||||
|
!subpass.pDepthStencilAttachment || subpass.inputAttachmentCount ||
|
||||||
|
subpass.preserveAttachmentCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto color = subpass.pColorAttachments[0].attachment;
|
||||||
|
const auto depth = subpass.pDepthStencilAttachment->attachment;
|
||||||
|
if (color >= info.attachmentCount || depth >= info.attachmentCount || color == depth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto samples = info.pAttachments[color].samples;
|
||||||
|
if (!IsFoveationSceneSampleCount(samples) || info.pAttachments[depth].samples != samples) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto resolve = subpass.pResolveAttachments ?
|
||||||
|
subpass.pResolveAttachments[0].attachment : VK_ATTACHMENT_UNUSED;
|
||||||
|
if (resolve != VK_ATTACHMENT_UNUSED) {
|
||||||
|
if (samples == VK_SAMPLE_COUNT_1_BIT || info.attachmentCount != 3 ||
|
||||||
|
resolve >= info.attachmentCount || resolve == color || resolve == depth ||
|
||||||
|
info.pAttachments[resolve].samples != VK_SAMPLE_COUNT_1_BIT ||
|
||||||
|
info.pAttachments[resolve].format != info.pAttachments[color].format) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (info.attachmentCount != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (unsigned i = 0; i < info.attachmentCount; ++i) {
|
||||||
|
if (info.pAttachments[i].flags) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
struct FoveationTileDriverIdentity {
|
||||||
|
std::uint32_t vendor_id = 0;
|
||||||
|
std::uint32_t device_id = 0;
|
||||||
|
std::uint32_t driver_id = 0;
|
||||||
|
std::uint32_t driver_version = 0;
|
||||||
|
std::string_view driver_name;
|
||||||
|
std::string_view driver_info;
|
||||||
|
std::array<std::uint8_t, 16> driver_uuid{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Values describe the complete render pass, including its density attachment.
|
||||||
|
// Attachment formats use VkFormat's numeric values without requiring Vulkan.
|
||||||
|
struct FoveationTilePassIdentity {
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
std::uint32_t color_format = 0;
|
||||||
|
std::uint32_t depth_format = 0;
|
||||||
|
std::uint32_t color_samples = 0;
|
||||||
|
std::uint32_t depth_samples = 0;
|
||||||
|
std::uint32_t framebuffer_layers = 0;
|
||||||
|
std::uint32_t subpass_count = 0;
|
||||||
|
std::uint32_t total_attachment_count = 0;
|
||||||
|
std::uint32_t color_attachment_count = 0;
|
||||||
|
std::uint32_t gmem_attachment_count = 0;
|
||||||
|
std::uint32_t resolve_attachment_mask = 0;
|
||||||
|
std::uint32_t input_attachment_mask = 0;
|
||||||
|
std::uint32_t feedback_attachment_mask = 0;
|
||||||
|
std::uint32_t view_mask = 0;
|
||||||
|
bool has_depth_attachment = false;
|
||||||
|
bool has_density_attachment = false;
|
||||||
|
bool density_offsets_enabled = false;
|
||||||
|
bool multisampled_render_to_single_sampled = false;
|
||||||
|
bool custom_resolve = false;
|
||||||
|
bool has_shading_rate_attachment = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FoveationTileBound {
|
||||||
|
std::uint32_t width;
|
||||||
|
std::uint32_t height;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A calibrated entry for one driver and pass tuple, not a hardware maximum.
|
||||||
|
// Unknown identities retain the caller's conservative policy. This exact entry
|
||||||
|
// passed 588 GPU focus cases; the matching driver ELF forces FDM's AVOID_CCU
|
||||||
|
// layout for all calibrated load/store combinations and the autotune path.
|
||||||
|
inline std::optional<FoveationTileBound> KnownFoveationTileBound(
|
||||||
|
const FoveationTileDriverIdentity& driver,
|
||||||
|
const FoveationTilePassIdentity& pass) {
|
||||||
|
constexpr std::array<std::uint8_t, 16> kDriverUuid{
|
||||||
|
0x58, 0x6b, 0x9f, 0xa4, 0x51, 0x4c, 0xb8, 0x8b,
|
||||||
|
0x3c, 0x11, 0xa9, 0x68, 0xef, 0x4d, 0x62, 0xe4};
|
||||||
|
if (driver.vendor_id != 0x5143 || driver.device_id != 0x43051401 ||
|
||||||
|
driver.driver_id != 18 || driver.driver_version != 109060195 ||
|
||||||
|
driver.driver_name != "turnip Mesa driver" ||
|
||||||
|
driver.driver_info != "Mesa 26.3.0-devel (git-abc426bb62)" ||
|
||||||
|
driver.driver_uuid != kDriverUuid) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (pass.width != 2160 || pass.height != 2160 ||
|
||||||
|
pass.color_format != 37 || pass.depth_format != 126 ||
|
||||||
|
pass.color_samples != 1 || pass.depth_samples != 1 ||
|
||||||
|
pass.framebuffer_layers != 1 || pass.subpass_count != 1 ||
|
||||||
|
pass.total_attachment_count != 3 || pass.color_attachment_count != 1 ||
|
||||||
|
pass.gmem_attachment_count != 2 ||
|
||||||
|
pass.resolve_attachment_mask || pass.input_attachment_mask ||
|
||||||
|
pass.feedback_attachment_mask || pass.view_mask ||
|
||||||
|
!pass.has_depth_attachment || !pass.has_density_attachment ||
|
||||||
|
pass.density_offsets_enabled ||
|
||||||
|
pass.multisampled_render_to_single_sampled || pass.custom_resolve ||
|
||||||
|
pass.has_shading_rate_attachment) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return FoveationTileBound{768, 448};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
struct Fsr3DeviceFeatureSupport {
|
||||||
|
bool shader_float16 = false;
|
||||||
|
bool shader_int16 = false;
|
||||||
|
bool float16_extension_available = false;
|
||||||
|
bool shader_storage_image_extended_formats = false;
|
||||||
|
bool shader_storage_image_write_without_format = false;
|
||||||
|
// The effective instance/device API version, not merely a header version.
|
||||||
|
std::uint32_t api_version = VK_API_VERSION_1_0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A call-scoped, owned device-create feature chain. This object must remain
|
||||||
|
// alive until xrCreateVulkanDeviceKHR/vkCreateDevice returns. Queue descriptions
|
||||||
|
// and layer names stay borrowed and unmodified, as in the original call.
|
||||||
|
// Unsupported or unfamiliar chains return the original create info verbatim.
|
||||||
|
class Fsr3DeviceFeatures final {
|
||||||
|
// The runtime normalizer reuses the same typed ownership/whitelist without
|
||||||
|
// relaxing the guest feature-chain validation performed by this class.
|
||||||
|
friend class Fsr3RuntimeTimelineFeatures;
|
||||||
|
public:
|
||||||
|
Fsr3DeviceFeatures(const VkDeviceCreateInfo& original,
|
||||||
|
const Fsr3DeviceFeatureSupport& support)
|
||||||
|
: original_(original), info_(original) {
|
||||||
|
Build(support);
|
||||||
|
}
|
||||||
|
Fsr3DeviceFeatures(const Fsr3DeviceFeatures&) = delete;
|
||||||
|
Fsr3DeviceFeatures& operator=(const Fsr3DeviceFeatures&) = delete;
|
||||||
|
Fsr3DeviceFeatures(Fsr3DeviceFeatures&&) = delete;
|
||||||
|
Fsr3DeviceFeatures& operator=(Fsr3DeviceFeatures&&) = delete;
|
||||||
|
|
||||||
|
const VkDeviceCreateInfo& create_info() const {
|
||||||
|
return enabled_ ? info_ : original_;
|
||||||
|
}
|
||||||
|
bool fp16_enabled() const { return enabled_; }
|
||||||
|
bool changed() const { return enabled_ && changed_; }
|
||||||
|
const char* reason() const { return reason_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Record {
|
||||||
|
virtual ~Record() = default;
|
||||||
|
virtual void* address() = 0;
|
||||||
|
virtual void next(void*) = 0;
|
||||||
|
};
|
||||||
|
template<class T> struct TypedRecord final : Record {
|
||||||
|
explicit TypedRecord(const T& source) : value(source) {}
|
||||||
|
void* address() override { return &value; }
|
||||||
|
void next(void* value_next) override { value.pNext = value_next; }
|
||||||
|
T value;
|
||||||
|
};
|
||||||
|
template<class T> T* Clone(const T& value) {
|
||||||
|
auto record = std::make_unique<TypedRecord<T>>(value);
|
||||||
|
auto* result = &record->value;
|
||||||
|
chain_.push_back(std::move(record));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
static constexpr std::uint64_t Bit(unsigned value) { return 1ull << value; }
|
||||||
|
static constexpr std::uint64_t Range(unsigned first, unsigned last) {
|
||||||
|
return ((1ull << (last + 1)) - 1) & ~((1ull << first) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CloneKnown(const VkBaseInStructure* source, std::uint64_t* families) {
|
||||||
|
std::uint64_t used = 0;
|
||||||
|
#define FEATURE(type, tag, mask) \
|
||||||
|
case tag: { \
|
||||||
|
used = (mask); \
|
||||||
|
if (*families & used) return false; \
|
||||||
|
Clone(*reinterpret_cast<const type*>(source)); \
|
||||||
|
break; \
|
||||||
|
}
|
||||||
|
switch (source->sType) {
|
||||||
|
case VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2: {
|
||||||
|
used = Bit(0);
|
||||||
|
if (*families & used) return false;
|
||||||
|
auto* value = Clone(*reinterpret_cast<const VkPhysicalDeviceFeatures2*>(source));
|
||||||
|
base_features_ = &value->features;
|
||||||
|
features2_ = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
FEATURE(VkPhysicalDeviceVulkan11Features,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES, Range(1, 6))
|
||||||
|
case VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES: {
|
||||||
|
used = Range(7, 19);
|
||||||
|
if (*families & used) return false;
|
||||||
|
auto* value = Clone(*reinterpret_cast<const VkPhysicalDeviceVulkan12Features*>(source));
|
||||||
|
float16_ = &value->shaderFloat16;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
FEATURE(VkPhysicalDeviceVulkan13Features,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES, Range(20, 32))
|
||||||
|
FEATURE(VkPhysicalDevice16BitStorageFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_16BIT_STORAGE_FEATURES, Bit(1))
|
||||||
|
FEATURE(VkPhysicalDeviceMultiviewFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTIVIEW_FEATURES, Bit(2))
|
||||||
|
FEATURE(VkPhysicalDeviceVariablePointersFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VARIABLE_POINTERS_FEATURES, Bit(3))
|
||||||
|
FEATURE(VkPhysicalDeviceProtectedMemoryFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROTECTED_MEMORY_FEATURES, Bit(4))
|
||||||
|
FEATURE(VkPhysicalDeviceSamplerYcbcrConversionFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SAMPLER_YCBCR_CONVERSION_FEATURES, Bit(5))
|
||||||
|
FEATURE(VkPhysicalDeviceShaderDrawParametersFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_DRAW_PARAMETERS_FEATURES, Bit(6))
|
||||||
|
FEATURE(VkPhysicalDevice8BitStorageFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_8BIT_STORAGE_FEATURES, Bit(7))
|
||||||
|
FEATURE(VkPhysicalDeviceShaderAtomicInt64Features,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_ATOMIC_INT64_FEATURES, Bit(8))
|
||||||
|
case VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_FLOAT16_INT8_FEATURES: {
|
||||||
|
used = Bit(9);
|
||||||
|
if (*families & used) return false;
|
||||||
|
auto* value = Clone(*reinterpret_cast<const VkPhysicalDeviceShaderFloat16Int8Features*>(source));
|
||||||
|
float16_ = &value->shaderFloat16;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
FEATURE(VkPhysicalDeviceDescriptorIndexingFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_INDEXING_FEATURES, Bit(10))
|
||||||
|
FEATURE(VkPhysicalDeviceScalarBlockLayoutFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SCALAR_BLOCK_LAYOUT_FEATURES, Bit(11))
|
||||||
|
FEATURE(VkPhysicalDeviceImagelessFramebufferFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGELESS_FRAMEBUFFER_FEATURES, Bit(12))
|
||||||
|
FEATURE(VkPhysicalDeviceUniformBufferStandardLayoutFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_UNIFORM_BUFFER_STANDARD_LAYOUT_FEATURES, Bit(13))
|
||||||
|
FEATURE(VkPhysicalDeviceShaderSubgroupExtendedTypesFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_SUBGROUP_EXTENDED_TYPES_FEATURES, Bit(14))
|
||||||
|
FEATURE(VkPhysicalDeviceSeparateDepthStencilLayoutsFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SEPARATE_DEPTH_STENCIL_LAYOUTS_FEATURES, Bit(15))
|
||||||
|
FEATURE(VkPhysicalDeviceHostQueryResetFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_HOST_QUERY_RESET_FEATURES, Bit(16))
|
||||||
|
FEATURE(VkPhysicalDeviceTimelineSemaphoreFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES, Bit(17))
|
||||||
|
FEATURE(VkPhysicalDeviceBufferDeviceAddressFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_BUFFER_DEVICE_ADDRESS_FEATURES, Bit(18))
|
||||||
|
FEATURE(VkPhysicalDeviceVulkanMemoryModelFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_MEMORY_MODEL_FEATURES, Bit(19))
|
||||||
|
FEATURE(VkPhysicalDeviceImageRobustnessFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_ROBUSTNESS_FEATURES, Bit(20))
|
||||||
|
FEATURE(VkPhysicalDeviceInlineUniformBlockFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_INLINE_UNIFORM_BLOCK_FEATURES, Bit(21))
|
||||||
|
FEATURE(VkPhysicalDevicePipelineCreationCacheControlFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PIPELINE_CREATION_CACHE_CONTROL_FEATURES, Bit(22))
|
||||||
|
FEATURE(VkPhysicalDevicePrivateDataFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRIVATE_DATA_FEATURES, Bit(23))
|
||||||
|
FEATURE(VkPhysicalDeviceShaderDemoteToHelperInvocationFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_DEMOTE_TO_HELPER_INVOCATION_FEATURES, Bit(24))
|
||||||
|
FEATURE(VkPhysicalDeviceShaderTerminateInvocationFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_TERMINATE_INVOCATION_FEATURES, Bit(25))
|
||||||
|
FEATURE(VkPhysicalDeviceSubgroupSizeControlFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_SIZE_CONTROL_FEATURES, Bit(26))
|
||||||
|
FEATURE(VkPhysicalDeviceSynchronization2Features,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SYNCHRONIZATION_2_FEATURES, Bit(27))
|
||||||
|
FEATURE(VkPhysicalDeviceTextureCompressionASTCHDRFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TEXTURE_COMPRESSION_ASTC_HDR_FEATURES, Bit(28))
|
||||||
|
FEATURE(VkPhysicalDeviceZeroInitializeWorkgroupMemoryFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ZERO_INITIALIZE_WORKGROUP_MEMORY_FEATURES, Bit(29))
|
||||||
|
FEATURE(VkPhysicalDeviceDynamicRenderingFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DYNAMIC_RENDERING_FEATURES, Bit(30))
|
||||||
|
FEATURE(VkPhysicalDeviceShaderIntegerDotProductFeatures,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_INTEGER_DOT_PRODUCT_FEATURES, Bit(31))
|
||||||
|
FEATURE(VkPhysicalDeviceMaintenance4Features,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MAINTENANCE_4_FEATURES, Bit(32))
|
||||||
|
FEATURE(VkPhysicalDeviceFragmentDensityMapFeaturesEXT,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_DENSITY_MAP_FEATURES_EXT, Bit(33))
|
||||||
|
FEATURE(VkPhysicalDeviceFragmentDensityMap2FeaturesEXT,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_DENSITY_MAP_2_FEATURES_EXT, Bit(34))
|
||||||
|
FEATURE(VkPhysicalDeviceHostImageCopyFeaturesEXT,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_HOST_IMAGE_COPY_FEATURES_EXT, Bit(35))
|
||||||
|
FEATURE(VkPhysicalDeviceFragmentShadingRateFeaturesKHR,
|
||||||
|
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_SHADING_RATE_FEATURES_KHR, Bit(36))
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
#undef FEATURE
|
||||||
|
*families |= used;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CopyExtensionNames() {
|
||||||
|
if (original_.enabledExtensionCount && !original_.ppEnabledExtensionNames)
|
||||||
|
return false;
|
||||||
|
for (std::uint32_t i = 0; i < original_.enabledExtensionCount; ++i) {
|
||||||
|
const char* name = original_.ppEnabledExtensionNames[i];
|
||||||
|
if (!name) return false;
|
||||||
|
std::size_t length = 0;
|
||||||
|
while (length < VK_MAX_EXTENSION_NAME_SIZE && name[length]) ++length;
|
||||||
|
if (!length || length == VK_MAX_EXTENSION_NAME_SIZE) return false;
|
||||||
|
const std::string value(name, length);
|
||||||
|
if (std::find(extension_names_.begin(), extension_names_.end(), value) == extension_names_.end())
|
||||||
|
extension_names_.push_back(value);
|
||||||
|
else changed_ = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Build(const Fsr3DeviceFeatureSupport& support) {
|
||||||
|
if (!support.shader_float16 || !support.shader_int16) {
|
||||||
|
reason_ = "shaderFloat16 or shaderInt16 unsupported";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (original_.sType != VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO) {
|
||||||
|
reason_ = "invalid device create info";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::uint64_t families = 0;
|
||||||
|
std::vector<const void*> seen;
|
||||||
|
for (auto* next = static_cast<const VkBaseInStructure*>(original_.pNext);
|
||||||
|
next; next = next->pNext) {
|
||||||
|
if (seen.size() == 64 || std::find(seen.begin(), seen.end(), next) != seen.end() ||
|
||||||
|
!CloneKnown(next, &families)) {
|
||||||
|
reason_ = "unknown, cyclic, or duplicate feature chain";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES)
|
||||||
|
// The Vulkan11 aggregate structure itself was introduced in1.2.
|
||||||
|
minimum_api_ = std::max(minimum_api_, VK_API_VERSION_1_2);
|
||||||
|
if (next->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES)
|
||||||
|
minimum_api_ = std::max(minimum_api_, VK_API_VERSION_1_2);
|
||||||
|
if (next->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES)
|
||||||
|
minimum_api_ = std::max(minimum_api_, VK_API_VERSION_1_3);
|
||||||
|
seen.push_back(next);
|
||||||
|
}
|
||||||
|
if (features2_ && original_.pEnabledFeatures) {
|
||||||
|
reason_ = "duplicate base feature records";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.api_version < minimum_api_) {
|
||||||
|
reason_ = "core feature record exceeds effective Vulkan API";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.api_version < VK_API_VERSION_1_2 && !support.float16_extension_available) {
|
||||||
|
reason_ = "shaderFloat16 extension unavailable";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!CopyExtensionNames()) {
|
||||||
|
reason_ = "invalid device extension list";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.api_version < VK_API_VERSION_1_2) {
|
||||||
|
const std::string extension = VK_KHR_SHADER_FLOAT16_INT8_EXTENSION_NAME;
|
||||||
|
if (std::find(extension_names_.begin(), extension_names_.end(), extension) == extension_names_.end()) {
|
||||||
|
extension_names_.push_back(extension);
|
||||||
|
changed_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!float16_) {
|
||||||
|
VkPhysicalDeviceShaderFloat16Int8Features value{};
|
||||||
|
value.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_FLOAT16_INT8_FEATURES;
|
||||||
|
float16_ = &Clone(value)->shaderFloat16;
|
||||||
|
changed_ = true;
|
||||||
|
}
|
||||||
|
if (!base_features_) {
|
||||||
|
if (original_.pEnabledFeatures) features_ = *original_.pEnabledFeatures;
|
||||||
|
base_features_ = &features_;
|
||||||
|
info_.pEnabledFeatures = base_features_;
|
||||||
|
}
|
||||||
|
changed_ |= *float16_ != VK_TRUE || base_features_->shaderInt16 != VK_TRUE;
|
||||||
|
*float16_ = VK_TRUE;
|
||||||
|
base_features_->shaderInt16 = VK_TRUE;
|
||||||
|
if (support.shader_storage_image_extended_formats) {
|
||||||
|
changed_ |= base_features_->shaderStorageImageExtendedFormats != VK_TRUE;
|
||||||
|
base_features_->shaderStorageImageExtendedFormats = VK_TRUE;
|
||||||
|
}
|
||||||
|
if (support.shader_storage_image_write_without_format) {
|
||||||
|
changed_ |= base_features_->shaderStorageImageWriteWithoutFormat != VK_TRUE;
|
||||||
|
base_features_->shaderStorageImageWriteWithoutFormat = VK_TRUE;
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < chain_.size(); ++i)
|
||||||
|
chain_[i]->next(i + 1 < chain_.size() ? chain_[i + 1]->address() : nullptr);
|
||||||
|
info_.pNext = chain_.empty() ? nullptr : chain_[0]->address();
|
||||||
|
for (const auto& name : extension_names_) extension_pointers_.push_back(name.c_str());
|
||||||
|
info_.enabledExtensionCount = static_cast<std::uint32_t>(extension_pointers_.size());
|
||||||
|
info_.ppEnabledExtensionNames = extension_pointers_.empty() ? nullptr : extension_pointers_.data();
|
||||||
|
enabled_ = true;
|
||||||
|
reason_ = "enabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
const VkDeviceCreateInfo& original_;
|
||||||
|
VkDeviceCreateInfo info_;
|
||||||
|
std::vector<std::unique_ptr<Record>> chain_;
|
||||||
|
std::vector<std::string> extension_names_;
|
||||||
|
std::vector<const char*> extension_pointers_;
|
||||||
|
VkPhysicalDeviceFeatures features_{};
|
||||||
|
VkPhysicalDeviceFeatures* base_features_ = nullptr;
|
||||||
|
VkBool32* float16_ = nullptr;
|
||||||
|
bool features2_ = false;
|
||||||
|
std::uint32_t minimum_api_ = VK_API_VERSION_1_0;
|
||||||
|
bool enabled_ = false;
|
||||||
|
bool changed_ = false;
|
||||||
|
const char* reason_ = "disabled";
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "mocktail/vr/fsr3_upscaler.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// A fixed-size temporal focus region with a cheap full-image periphery.
|
||||||
|
// The owner retires pending GPU users before reusing descriptors or resetting.
|
||||||
|
class Fsr3GazeTransport {
|
||||||
|
public:
|
||||||
|
struct BeginInfo {
|
||||||
|
VkImage encoded_source{}; // TRANSFER_SRC_OPTIMAL; encoded UNORM or sRGB.
|
||||||
|
Fsr3Upscaler::Image depth; // Full-input size, SHADER_READ_ONLY_OPTIMAL.
|
||||||
|
Fsr3Upscaler::Image motion; // Optional native clip-NDC delta, same layout/size.
|
||||||
|
float gaze_x=.5f, gaze_y=.5f; // Normalized image UV, +Y down.
|
||||||
|
bool reset=false;
|
||||||
|
};
|
||||||
|
struct Dimensions {
|
||||||
|
std::uint32_t input_width{},input_height{},output_width{},output_height{};
|
||||||
|
};
|
||||||
|
Fsr3GazeTransport();
|
||||||
|
~Fsr3GazeTransport();
|
||||||
|
Fsr3GazeTransport(const Fsr3GazeTransport&)=delete;
|
||||||
|
Fsr3GazeTransport& operator=(const Fsr3GazeTransport&)=delete;
|
||||||
|
bool Prepare(VkDevice,PFN_vkGetDeviceProcAddr,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&,VkFormat encoded_format,
|
||||||
|
std::uint32_t full_input_width,std::uint32_t full_input_height,
|
||||||
|
std::uint32_t full_output_width,std::uint32_t full_output_height,
|
||||||
|
float sharp_radius);
|
||||||
|
bool Begin(VkCommandBuffer,int eye,const BeginInfo&);
|
||||||
|
void Finish(VkCommandBuffer,int eye);
|
||||||
|
Dimensions dimensions()const;
|
||||||
|
float vertical_fov_scale()const;
|
||||||
|
bool reset_history(int eye)const;
|
||||||
|
Fsr3Upscaler::Image color(int eye)const;
|
||||||
|
Fsr3Upscaler::Image depth(int eye)const;
|
||||||
|
Fsr3Upscaler::Image motion(int eye)const;
|
||||||
|
Fsr3Upscaler::Image output(int eye)const;
|
||||||
|
VkImage encoded(int eye)const;
|
||||||
|
VkFormat encoded_format()const;
|
||||||
|
void Reset();
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstring>
|
||||||
|
#include "mocktail/vr/fsr3_device_features.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// SteamVR can append a TimelineSemaphore feature record even when a guest
|
||||||
|
// already supplied Vulkan12Features. Fold that single promoted feature into
|
||||||
|
// its aggregate immediately before the host vkCreateDevice call. Every known
|
||||||
|
// record is cloned so no borrowed pNext pointer is rewritten. Unknown records,
|
||||||
|
// conflicting records, and malformed chains pass through verbatim.
|
||||||
|
class Fsr3RuntimeTimelineFeatures final {
|
||||||
|
public:
|
||||||
|
explicit Fsr3RuntimeTimelineFeatures(const VkDeviceCreateInfo& original)
|
||||||
|
: owned_(original, Fsr3DeviceFeatureSupport{}) {
|
||||||
|
if (original.sType != VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO) return;
|
||||||
|
const VkPhysicalDeviceVulkan12Features* aggregate = nullptr;
|
||||||
|
const VkPhysicalDeviceTimelineSemaphoreFeatures* timeline = nullptr;
|
||||||
|
std::vector<const VkBaseInStructure*> records;
|
||||||
|
for (auto* record = static_cast<const VkBaseInStructure*>(original.pNext);
|
||||||
|
record; record = record->pNext) {
|
||||||
|
if (records.size() == 64 ||
|
||||||
|
std::find(records.begin(), records.end(), record) != records.end()) return;
|
||||||
|
if (record->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES) {
|
||||||
|
if (aggregate) return;
|
||||||
|
aggregate = reinterpret_cast<const VkPhysicalDeviceVulkan12Features*>(record);
|
||||||
|
}
|
||||||
|
if (record->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES) {
|
||||||
|
if (timeline) return;
|
||||||
|
timeline = reinterpret_cast<const VkPhysicalDeviceTimelineSemaphoreFeatures*>(record);
|
||||||
|
}
|
||||||
|
records.push_back(record);
|
||||||
|
}
|
||||||
|
if (!aggregate || !timeline) return;
|
||||||
|
std::uint64_t families = 0;
|
||||||
|
VkPhysicalDeviceVulkan12Features* cloned_aggregate = nullptr;
|
||||||
|
for (auto* record : records) {
|
||||||
|
if (record == reinterpret_cast<const VkBaseInStructure*>(timeline)) continue;
|
||||||
|
if (!owned_.CloneKnown(record, &families)) return;
|
||||||
|
if (record == reinterpret_cast<const VkBaseInStructure*>(aggregate))
|
||||||
|
cloned_aggregate = static_cast<VkPhysicalDeviceVulkan12Features*>(
|
||||||
|
owned_.chain_.back()->address());
|
||||||
|
}
|
||||||
|
if (!cloned_aggregate || (owned_.features2_ && original.pEnabledFeatures)) return;
|
||||||
|
cloned_aggregate->timelineSemaphore =
|
||||||
|
aggregate->timelineSemaphore || timeline->timelineSemaphore ? VK_TRUE : VK_FALSE;
|
||||||
|
for (std::size_t i = 0; i < owned_.chain_.size(); ++i)
|
||||||
|
owned_.chain_[i]->next(i + 1 < owned_.chain_.size()
|
||||||
|
? owned_.chain_[i + 1]->address() : nullptr);
|
||||||
|
owned_.info_.pNext = owned_.chain_.front()->address();
|
||||||
|
changed_ = true;
|
||||||
|
}
|
||||||
|
Fsr3RuntimeTimelineFeatures(const Fsr3RuntimeTimelineFeatures&) = delete;
|
||||||
|
Fsr3RuntimeTimelineFeatures& operator=(const Fsr3RuntimeTimelineFeatures&) = delete;
|
||||||
|
const VkDeviceCreateInfo& create_info() const {
|
||||||
|
return changed_ ? owned_.info_ : owned_.original_;
|
||||||
|
}
|
||||||
|
bool changed() const { return changed_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
Fsr3DeviceFeatures owned_;
|
||||||
|
bool changed_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only calls made in this thread's scope normalize vkCreateDevice. Retained
|
||||||
|
// callbacks and calls on other threads use an atomic snapshot of the raw host
|
||||||
|
// dispatch, without normalization. The backend must clear that fallback before
|
||||||
|
// unloading its private host Vulkan library.
|
||||||
|
class ScopedFsr3VulkanDeviceCreate final {
|
||||||
|
public:
|
||||||
|
ScopedFsr3VulkanDeviceCreate(PFN_vkGetInstanceProcAddr raw, VkInstance instance)
|
||||||
|
: raw_(raw), previous_(current_) {
|
||||||
|
if (raw_)
|
||||||
|
create_ = reinterpret_cast<PFN_vkCreateDevice>(raw_(instance, "vkCreateDevice"));
|
||||||
|
if (create_ && create_ != &CreateDevice) {
|
||||||
|
current_ = this;
|
||||||
|
std::shared_ptr<const RawDispatch> dispatch =
|
||||||
|
std::make_shared<RawDispatch>(RawDispatch{raw_, create_});
|
||||||
|
std::atomic_store_explicit(&fallback_, std::move(dispatch), std::memory_order_release);
|
||||||
|
} else create_ = nullptr;
|
||||||
|
}
|
||||||
|
~ScopedFsr3VulkanDeviceCreate() {
|
||||||
|
if (current_ == this) current_ = previous_;
|
||||||
|
}
|
||||||
|
ScopedFsr3VulkanDeviceCreate(const ScopedFsr3VulkanDeviceCreate&) = delete;
|
||||||
|
ScopedFsr3VulkanDeviceCreate& operator=(const ScopedFsr3VulkanDeviceCreate&) = delete;
|
||||||
|
PFN_vkGetInstanceProcAddr get_instance_proc_addr() const {
|
||||||
|
return create_ ? &GetInstanceProcAddr : raw_;
|
||||||
|
}
|
||||||
|
bool active() const { return create_ != nullptr; }
|
||||||
|
static void ClearFallback() {
|
||||||
|
std::atomic_store_explicit(&fallback_, std::shared_ptr<const RawDispatch>{},
|
||||||
|
std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct RawDispatch {
|
||||||
|
PFN_vkGetInstanceProcAddr gipa = nullptr;
|
||||||
|
PFN_vkCreateDevice create = nullptr;
|
||||||
|
};
|
||||||
|
static VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL GetInstanceProcAddr(
|
||||||
|
VkInstance instance, const char* name) {
|
||||||
|
auto* scope = current_;
|
||||||
|
if (!scope || !scope->raw_) {
|
||||||
|
const auto dispatch = std::atomic_load_explicit(&fallback_, std::memory_order_acquire);
|
||||||
|
return dispatch && dispatch->gipa ? dispatch->gipa(instance, name) : nullptr;
|
||||||
|
}
|
||||||
|
if (name && std::strcmp(name, "vkCreateDevice") == 0)
|
||||||
|
return reinterpret_cast<PFN_vkVoidFunction>(&CreateDevice);
|
||||||
|
return scope->raw_(instance, name);
|
||||||
|
}
|
||||||
|
static VKAPI_ATTR VkResult VKAPI_CALL CreateDevice(
|
||||||
|
VkPhysicalDevice physical, const VkDeviceCreateInfo* info,
|
||||||
|
const VkAllocationCallbacks* allocator, VkDevice* device) {
|
||||||
|
auto* scope = current_;
|
||||||
|
if (!scope || !scope->create_) {
|
||||||
|
const auto dispatch = std::atomic_load_explicit(&fallback_, std::memory_order_acquire);
|
||||||
|
return dispatch && dispatch->create
|
||||||
|
? dispatch->create(physical, info, allocator, device)
|
||||||
|
: VK_ERROR_INITIALIZATION_FAILED;
|
||||||
|
}
|
||||||
|
if (!info) return scope->create_(physical, info, allocator, device);
|
||||||
|
Fsr3RuntimeTimelineFeatures normalized(*info);
|
||||||
|
return scope->create_(physical, &normalized.create_info(), allocator, device);
|
||||||
|
}
|
||||||
|
inline static thread_local ScopedFsr3VulkanDeviceCreate* current_ = nullptr;
|
||||||
|
inline static std::shared_ptr<const RawDispatch> fallback_;
|
||||||
|
PFN_vkGetInstanceProcAddr raw_ = nullptr;
|
||||||
|
PFN_vkCreateDevice create_ = nullptr;
|
||||||
|
ScopedFsr3VulkanDeviceCreate* previous_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
#include "mocktail/vr/fsr3_scene_inputs.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
// Owns depth snapshots, recorded before the guest reuses its scene depth.
|
||||||
|
// All methods are serialized by the backend and use its single graphics queue.
|
||||||
|
class Fsr3SceneCapture {
|
||||||
|
public:
|
||||||
|
struct Eye {
|
||||||
|
VkImage depth{};
|
||||||
|
VkDeviceMemory memory{};
|
||||||
|
VkFormat format{};
|
||||||
|
std::uint32_t width{},height{};
|
||||||
|
std::uint64_t submitted_frame{};
|
||||||
|
bool initialized{};
|
||||||
|
};
|
||||||
|
struct Motion {
|
||||||
|
VkImage image{};VkDeviceMemory memory{};VkFormat format{};
|
||||||
|
std::uint32_t width{},height{};std::uint64_t submitted_frame{};
|
||||||
|
float previous_uv_scale_x=-.5f,previous_uv_scale_y=.5f;
|
||||||
|
};
|
||||||
|
~Fsr3SceneCapture() { Reset(); }
|
||||||
|
bool NeedsResize(const TemporalSceneInputs&) const;
|
||||||
|
bool Record(VkDevice, PFN_vkGetDeviceProcAddr,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&, VkCommandBuffer,
|
||||||
|
const TemporalSceneInputs&, std::uint64_t frame);
|
||||||
|
bool RecordMotion(VkDevice, PFN_vkGetDeviceProcAddr,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&, VkCommandBuffer,
|
||||||
|
const TemporalMotionInputs&, std::uint64_t frame);
|
||||||
|
void Submitted(std::uint32_t,const VkCommandBuffer*);
|
||||||
|
void Discard(VkCommandBuffer);
|
||||||
|
bool HasUnsubmitted() const;
|
||||||
|
const Eye* Get(int eye,std::uint64_t frame,std::uint32_t width,
|
||||||
|
std::uint32_t height) const;
|
||||||
|
const Motion* GetMotion(int eye,std::uint64_t frame,std::uint32_t width,
|
||||||
|
std::uint32_t height) const;
|
||||||
|
void Reset();
|
||||||
|
private:
|
||||||
|
template<class T> T Proc(const char* name) const {
|
||||||
|
return reinterpret_cast<T>(proc_(device_,name));
|
||||||
|
}
|
||||||
|
bool Create(Eye&,const TemporalSceneInputs&,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&);
|
||||||
|
bool Allocate(VkImage&,VkDeviceMemory&,VkFormat,std::uint32_t,std::uint32_t,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&);
|
||||||
|
void Destroy(Eye&);
|
||||||
|
VkDevice device_{};
|
||||||
|
PFN_vkGetDeviceProcAddr proc_{};
|
||||||
|
std::array<Eye,2> eyes_{};
|
||||||
|
std::array<Motion,2> motion_{};
|
||||||
|
struct Pending {std::array<std::uint64_t,2> depth{},motion{};bool submitted=false;};
|
||||||
|
std::unordered_map<VkCommandBuffer,Pending> pending_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// A scene-pass candidate, not an asynchronous borrow. This is delivered while
|
||||||
|
// the guest command buffer is recording, immediately after its render pass.
|
||||||
|
// The backend must verify owner/eye/generation, then record any required copies
|
||||||
|
// on that command buffer before returning. Image handles may be reused by the
|
||||||
|
// very next guest pass. A later queue submission/fence establishes completion.
|
||||||
|
struct TemporalSceneInputs {
|
||||||
|
std::uint32_t version = 1;
|
||||||
|
std::uint32_t size = sizeof(TemporalSceneInputs);
|
||||||
|
VkFramebuffer framebuffer = VK_NULL_HANDLE;
|
||||||
|
VkImage color = VK_NULL_HANDLE;
|
||||||
|
VkImage depth = VK_NULL_HANDLE;
|
||||||
|
VkFormat color_format = VK_FORMAT_UNDEFINED;
|
||||||
|
VkFormat depth_format = VK_FORMAT_UNDEFINED;
|
||||||
|
VkImageLayout color_layout = VK_IMAGE_LAYOUT_UNDEFINED;
|
||||||
|
VkImageLayout depth_layout = VK_IMAGE_LAYOUT_UNDEFINED;
|
||||||
|
VkImageSubresourceRange color_range{};
|
||||||
|
VkImageSubresourceRange depth_range{};
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
// Set only when this pass actually clears its depth attachment. This is
|
||||||
|
// evidence for normal (clear=1) versus reversed (clear=0) device depth.
|
||||||
|
float depth_clear = 0.f;
|
||||||
|
bool depth_clear_valid = false;
|
||||||
|
void* owner = nullptr;
|
||||||
|
void* guest_framebuffer = nullptr;
|
||||||
|
int eye = -1;
|
||||||
|
// Zero means the adapter has no frame number. The backend must stamp the
|
||||||
|
// current published frame after matching the exact live eye provenance.
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native 3222 motion shader output before conversion: current clip.xyz/w minus
|
||||||
|
// previous clip.xyz/w. Its vertex shader negates Y only for gl_Position, so
|
||||||
|
// previous-UV minus current-UV is nativeDelta.xy * (-0.5, +0.5). XYZ are signed
|
||||||
|
// floats; W is zero, not a validity mask. The shader can discard large deltas.
|
||||||
|
struct TemporalMotionInputs {
|
||||||
|
std::uint32_t version = 1;
|
||||||
|
std::uint32_t size = sizeof(TemporalMotionInputs);
|
||||||
|
VkFramebuffer framebuffer = VK_NULL_HANDLE;
|
||||||
|
VkImage image = VK_NULL_HANDLE;
|
||||||
|
VkFormat format = VK_FORMAT_UNDEFINED;
|
||||||
|
VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED;
|
||||||
|
VkImageSubresourceRange range{};
|
||||||
|
std::uint32_t width = 0, height = 0;
|
||||||
|
void* owner = nullptr;
|
||||||
|
void* guest_framebuffer = nullptr;
|
||||||
|
int eye = -1;
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
float previous_uv_scale_x = -.5f;
|
||||||
|
float previous_uv_scale_y = .5f;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool IsTemporalMotionFormat(VkFormat format) {
|
||||||
|
return format == VK_FORMAT_R16G16_SFLOAT ||
|
||||||
|
format == VK_FORMAT_R16G16B16A16_SFLOAT ||
|
||||||
|
format == VK_FORMAT_R32G32_SFLOAT ||
|
||||||
|
format == VK_FORMAT_R32G32B32A32_SFLOAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool IsTemporalClipMotionShaders(std::uint64_t vertex,
|
||||||
|
std::uint64_t fragment) {
|
||||||
|
// FNV-1a of exact native shader module bytes, verified against both the
|
||||||
|
// fragment subtraction and vertex current/previous clip outputs. The three
|
||||||
|
// adjacent world-space variants deliberately have a different contract.
|
||||||
|
return fragment == 0x821fff499ec5bdbbull &&
|
||||||
|
(vertex == 0x9b0d6a7041cad338ull ||
|
||||||
|
vertex == 0x4a99a561ef9805b6ull ||
|
||||||
|
vertex == 0x6dcaa6712cd5a2cdull);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool IsTemporalDepthFormat(VkFormat format) {
|
||||||
|
return format == VK_FORMAT_D16_UNORM ||
|
||||||
|
format == VK_FORMAT_X8_D24_UNORM_PACK32 ||
|
||||||
|
format == VK_FORMAT_D32_SFLOAT ||
|
||||||
|
format == VK_FORMAT_D16_UNORM_S8_UINT ||
|
||||||
|
format == VK_FORMAT_D24_UNORM_S8_UINT ||
|
||||||
|
format == VK_FORMAT_D32_SFLOAT_S8_UINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool IsTemporalColorFormat(VkFormat format) {
|
||||||
|
return format == VK_FORMAT_R8G8B8A8_UNORM ||
|
||||||
|
format == VK_FORMAT_R8G8B8A8_SRGB ||
|
||||||
|
format == VK_FORMAT_B8G8R8A8_UNORM ||
|
||||||
|
format == VK_FORMAT_B8G8R8A8_SRGB ||
|
||||||
|
format == VK_FORMAT_R16G16B16A16_SFLOAT ||
|
||||||
|
format == VK_FORMAT_B10G11R11_UFLOAT_PACK32 ||
|
||||||
|
format == VK_FORMAT_A2B10G10R10_UNORM_PACK32;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool IsTemporalImageCandidate(const VkImageCreateInfo& info) {
|
||||||
|
const bool depth = IsTemporalDepthFormat(info.format) &&
|
||||||
|
(info.usage & VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT);
|
||||||
|
const bool color = IsTemporalColorFormat(info.format) &&
|
||||||
|
(info.usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT);
|
||||||
|
const bool motion = IsTemporalMotionFormat(info.format) &&
|
||||||
|
(info.usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT);
|
||||||
|
// Native transient targets may request lazy memory; changing their usage would
|
||||||
|
// disagree with the engine allocator's original memory-type request.
|
||||||
|
return (depth || color || motion) &&
|
||||||
|
!(info.usage & VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT) &&
|
||||||
|
info.imageType == VK_IMAGE_TYPE_2D &&
|
||||||
|
info.tiling == VK_IMAGE_TILING_OPTIMAL &&
|
||||||
|
info.samples == VK_SAMPLE_COUNT_1_BIT && info.extent.depth == 1 &&
|
||||||
|
info.extent.width >= 256 && info.extent.height >= 256 &&
|
||||||
|
info.arrayLayers == 1 && info.mipLevels == 1 && info.flags == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth/stencil formats use depth only. FSR samples device depth; stencil must
|
||||||
|
// never be reinterpreted as scene depth or included in a depth-only copy.
|
||||||
|
inline VkImageAspectFlags TemporalDepthAspect(VkFormat format) {
|
||||||
|
return IsTemporalDepthFormat(format) ? VK_IMAGE_ASPECT_DEPTH_BIT : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
// Availability tracks compatible resources from Vulkan startup. Enabled only
|
||||||
|
// selects preserving passes and per-frame capture while temporal FSR is active.
|
||||||
|
bool mocktail_vr_xr_fsr3_inputs_available();
|
||||||
|
bool mocktail_vr_xr_fsr3_inputs_enabled();
|
||||||
|
void mocktail_vr_xr_fsr3_scene_end(
|
||||||
|
VkDevice device, VkCommandBuffer command,
|
||||||
|
const mocktail::vr::TemporalSceneInputs* inputs);
|
||||||
|
void mocktail_vr_xr_fsr3_motion_end(
|
||||||
|
VkDevice device, VkCommandBuffer command,
|
||||||
|
const mocktail::vr::TemporalMotionInputs* inputs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
#include "mocktail/vr/fsr3_upscaler.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
// Explicit encoded-native -> linear-FSR -> encoded-native transport.
|
||||||
|
// The caller drains uses before resizing/resetting these reusable eye images.
|
||||||
|
class Fsr3Transport {
|
||||||
|
public:
|
||||||
|
~Fsr3Transport() { Reset(); }
|
||||||
|
bool Prepare(VkDevice,PFN_vkGetDeviceProcAddr,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&,VkFormat source,
|
||||||
|
std::uint32_t width,std::uint32_t height,
|
||||||
|
std::uint32_t output_width,std::uint32_t output_height);
|
||||||
|
void Begin(VkCommandBuffer,int eye,VkImage encoded_source);
|
||||||
|
void Finish(VkCommandBuffer,int eye);
|
||||||
|
Fsr3Upscaler::Image color(int eye) const;
|
||||||
|
Fsr3Upscaler::Image output(int eye) const;
|
||||||
|
VkImage encoded(int eye) const {return eyes_[eye].encoded.image;}
|
||||||
|
VkFormat encoded_format() const {return srgb_format_;}
|
||||||
|
void Reset();
|
||||||
|
private:
|
||||||
|
struct Resource {VkImage image{};VkDeviceMemory memory{};};
|
||||||
|
struct Eye {Resource color,output,encoded;};
|
||||||
|
template<class T>T Proc(const char* name)const{return reinterpret_cast<T>(proc_(device_,name));}
|
||||||
|
bool Create(Resource&,VkFormat,std::uint32_t,std::uint32_t,VkImageUsageFlags,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&);
|
||||||
|
void Destroy(Resource&);
|
||||||
|
void Barrier(VkCommandBuffer,VkImage,VkImageLayout,VkImageLayout,VkAccessFlags,VkAccessFlags);
|
||||||
|
VkDevice device_{};
|
||||||
|
PFN_vkGetDeviceProcAddr proc_{};
|
||||||
|
VkFormat source_format_{},srgb_format_{};
|
||||||
|
std::uint32_t width_{},height_{},output_width_{},output_height_{};
|
||||||
|
std::array<Eye,2> eyes_{};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// AMD FSR 3.1.4 temporal upscaling. Each eye owns independent temporal history.
|
||||||
|
// GPU completion is the owner's responsibility before Reset or destruction.
|
||||||
|
class Fsr3Upscaler {
|
||||||
|
public:
|
||||||
|
struct Configuration {
|
||||||
|
std::uint32_t render_width{}, render_height{};
|
||||||
|
std::uint32_t output_width{}, output_height{};
|
||||||
|
bool inverted_depth=false, infinite_depth=false, hdr=false;
|
||||||
|
// Estimate image motion with AMD Optical Flow when engine vectors are
|
||||||
|
// unavailable. This is a fallback with its own compute cost and limits.
|
||||||
|
bool estimate_motion=false;
|
||||||
|
bool motion_vectors_jittered=false;
|
||||||
|
// Enable only when shaderFloat16 was enabled on the logical VkDevice.
|
||||||
|
bool allow_fp16=false;
|
||||||
|
};
|
||||||
|
struct Image {
|
||||||
|
VkImage image{};
|
||||||
|
VkFormat format{};
|
||||||
|
std::uint32_t width{}, height{};
|
||||||
|
};
|
||||||
|
struct JitterOffset { float x{}, y{}; };
|
||||||
|
struct Frame {
|
||||||
|
// Inputs must be sampled images in SHADER_READ_ONLY_OPTIMAL, output a
|
||||||
|
// storage image in GENERAL. The SDK restores those layouts after dispatch.
|
||||||
|
// Color is LINEAR: an sRGB image format decodes encoded source bytes.
|
||||||
|
// The output should be RGBA16F linear; encode it before copying to an
|
||||||
|
// encoded UNORM eye image. Depth and color must describe the same view.
|
||||||
|
Image color, depth, motion_vectors, output;
|
||||||
|
Image reactive, transparency;
|
||||||
|
// Unjittered motion is previous pixel position minus current position.
|
||||||
|
// Pixel-unit vectors use {1,1}; normalized UV vectors use render size.
|
||||||
|
float motion_vector_scale[2]{1.f,1.f};
|
||||||
|
JitterOffset jitter;
|
||||||
|
float delta_ms=16.667f, near_plane=.1f, far_plane=1000.f;
|
||||||
|
float vertical_fov=1.5707963f, meters_per_world_unit=1.f;
|
||||||
|
float pre_exposure=1.f, sharpness=.2f;
|
||||||
|
bool sharpening=true, reset=false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Fsr3Upscaler();
|
||||||
|
~Fsr3Upscaler();
|
||||||
|
Fsr3Upscaler(const Fsr3Upscaler&)=delete;
|
||||||
|
Fsr3Upscaler& operator=(const Fsr3Upscaler&)=delete;
|
||||||
|
bool Configure(VkPhysicalDevice, VkDevice, PFN_vkGetDeviceProcAddr,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&, const Configuration&);
|
||||||
|
bool Record(VkCommandBuffer, int eye, const Frame&);
|
||||||
|
Image estimated_motion(int eye) const;
|
||||||
|
void Reset();
|
||||||
|
int last_error() const;
|
||||||
|
static JitterOffset Jitter(std::uint64_t frame, std::uint32_t render_width,
|
||||||
|
std::uint32_t output_width);
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
struct FsrSettings {
|
||||||
|
int quality=0; // Custom scale, Ultra Quality, Quality, Balanced, Performance, Custom resolution.
|
||||||
|
int scale_percent=80;
|
||||||
|
std::uint32_t input_resolution=1728;
|
||||||
|
bool operator==(const FsrSettings& other) const {
|
||||||
|
return quality==other.quality && scale_percent==other.scale_percent &&
|
||||||
|
input_resolution==other.input_resolution;
|
||||||
|
}
|
||||||
|
bool operator!=(const FsrSettings& other) const { return !(*this==other); }
|
||||||
|
};
|
||||||
|
|
||||||
|
inline std::uint32_t FsrRenderSize(std::uint32_t output, int mode,
|
||||||
|
const FsrSettings& settings={}) {
|
||||||
|
if((mode!=1 && mode!=2 && mode!=3) || output<2)return output;
|
||||||
|
if(settings.quality==5)
|
||||||
|
return std::clamp(settings.input_resolution,2u,output)&~1u;
|
||||||
|
std::uint64_t numerator=std::clamp(settings.scale_percent,25,100),denominator=100;
|
||||||
|
switch(settings.quality) {
|
||||||
|
case 1:numerator=10;denominator=13;break;
|
||||||
|
case 2:numerator=2;denominator=3;break;
|
||||||
|
case 3:numerator=10;denominator=17;break;
|
||||||
|
case 4:numerator=1;denominator=2;break;
|
||||||
|
default:break;
|
||||||
|
}
|
||||||
|
const auto scaled=static_cast<std::uint32_t>(
|
||||||
|
2*((std::uint64_t(output)*numerator+denominator)/(2*denominator)));
|
||||||
|
return std::clamp(scaled,2u,output&~1u);
|
||||||
|
}
|
||||||
|
inline std::uint32_t FsrRenderHeight(std::uint32_t output_width,
|
||||||
|
std::uint32_t output_height,int mode,
|
||||||
|
const FsrSettings& settings={}) {
|
||||||
|
if(settings.quality!=5 || mode==0 || !output_width || output_height<2)
|
||||||
|
return FsrRenderSize(output_height,mode,settings);
|
||||||
|
const auto width=FsrRenderSize(output_width,mode,settings);
|
||||||
|
const auto height=static_cast<std::uint32_t>(
|
||||||
|
2*((std::uint64_t(output_height)*width+output_width)/(2*std::uint64_t(output_width))));
|
||||||
|
return std::clamp(height,2u,output_height&~1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
#include "mocktail/vr/fsr_settings.h"
|
||||||
|
#include "mocktail/vr/temporal_reprojection.h"
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
// FP32 FSR 1 EASU, followed by gaze-only RCAS after the normal gaze AA.
|
||||||
|
// Uses raw host dispatch and the backend's existing copy command/fence.
|
||||||
|
class FsrUpscaler {
|
||||||
|
public:
|
||||||
|
~FsrUpscaler() { Reset(); }
|
||||||
|
bool Prepare(VkDevice, PFN_vkGetDeviceProcAddr,
|
||||||
|
const VkPhysicalDeviceMemoryProperties&, int eye,
|
||||||
|
VkImage source, VkFormat source_format, VkImage destination,
|
||||||
|
VkFormat output_format, std::uint32_t input_width,
|
||||||
|
std::uint32_t input_height, std::uint32_t output_width,
|
||||||
|
std::uint32_t output_height, bool temporal=false);
|
||||||
|
void RecordUpscale(VkCommandBuffer, int eye, float gaze_x=0, float gaze_y=0,
|
||||||
|
float radius=.15f);
|
||||||
|
void RecordTemporal(VkCommandBuffer,int eye,const TemporalEyePose&,
|
||||||
|
float gaze_x,float gaze_y,float radius,std::uint64_t now);
|
||||||
|
void RecordSharpen(VkCommandBuffer, int eye, float gaze_x, float gaze_y,
|
||||||
|
float radius);
|
||||||
|
VkImage image(int eye) const { return eyes_[eye].image; }
|
||||||
|
VkFormat intermediate_format() const;
|
||||||
|
void ForgetImage(VkImage);
|
||||||
|
void Reset();
|
||||||
|
private:
|
||||||
|
template<class T> T Proc(const char* name) const {
|
||||||
|
return reinterpret_cast<T>(proc_(device_, name));
|
||||||
|
}
|
||||||
|
bool Initialize(VkDevice, PFN_vkGetDeviceProcAddr, VkFormat);
|
||||||
|
VkPipeline Pipeline(VkRenderPass, const std::uint32_t*, std::size_t);
|
||||||
|
struct Eye {
|
||||||
|
VkImage image{}, source{}, destination{}, gamma_image{};
|
||||||
|
VkDeviceMemory memory{}, gamma_memory{};
|
||||||
|
VkImageView view{}, source_view{};
|
||||||
|
VkFramebuffer framebuffer{};
|
||||||
|
VkDescriptorSet sets[3]{};
|
||||||
|
VkImage history_image{};VkDeviceMemory history_memory{};VkImageView history_view{};
|
||||||
|
bool history_initialized=false,history_failed=false;
|
||||||
|
TemporalEyePose previous_pose;
|
||||||
|
float previous_center[2]{},previous_radius=0;
|
||||||
|
std::uint64_t previous_time=0;
|
||||||
|
VkFormat source_format{};
|
||||||
|
std::uint32_t in_width=0, in_height=0, width=0, height=0;
|
||||||
|
};
|
||||||
|
struct Target { VkImageView view{}; VkFramebuffer framebuffer{}; };
|
||||||
|
void FreeEye(Eye&);
|
||||||
|
bool PrepareHistory(const VkPhysicalDeviceMemoryProperties&,int eye);
|
||||||
|
struct alignas(16) Constants {
|
||||||
|
std::uint32_t con[4][4]{};
|
||||||
|
float texel[2]{}, center[2]{};
|
||||||
|
float radius=0;
|
||||||
|
std::uint32_t input_srgb=0, output_srgb=0, reserved=0;
|
||||||
|
};
|
||||||
|
static_assert(sizeof(Constants)==96);
|
||||||
|
struct alignas(16) TemporalConstants {
|
||||||
|
float rows[3][4]{},texel[2]{},center[2]{},previous_center[2]{};
|
||||||
|
float radius=0,previous_radius=0,blend=0;
|
||||||
|
std::uint32_t output_srgb=0,reserved[2]{};
|
||||||
|
};
|
||||||
|
static_assert(sizeof(TemporalConstants)==96);
|
||||||
|
void Draw(VkCommandBuffer, VkRenderPass, VkFramebuffer, VkPipeline,
|
||||||
|
VkDescriptorSet, const void*, const Eye&, VkRect2D);
|
||||||
|
void Barrier(VkCommandBuffer, VkImage, VkImageLayout, VkImageLayout,
|
||||||
|
VkAccessFlags, VkAccessFlags, VkPipelineStageFlags,
|
||||||
|
VkPipelineStageFlags);
|
||||||
|
VkDevice device_{};
|
||||||
|
PFN_vkGetDeviceProcAddr proc_{};
|
||||||
|
VkFormat format_{};
|
||||||
|
VkRenderPass passes_[2]{};
|
||||||
|
VkPipeline pipelines_[3]{};
|
||||||
|
VkDescriptorSetLayout descriptor_layout_{};
|
||||||
|
VkDescriptorPool descriptor_pool_{};
|
||||||
|
VkPipelineLayout layout_{};
|
||||||
|
VkSampler sampler_{};
|
||||||
|
Eye eyes_[2];
|
||||||
|
std::unordered_map<VkImage,Target> targets_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// Gaze-limited edge filtering after the normal eye copy.
|
||||||
|
// Every call uses raw host dispatch. The owner waits its copy fence before
|
||||||
|
// changing descriptors, dropping views, or destroying resources.
|
||||||
|
class GazeAa {
|
||||||
|
public:
|
||||||
|
~GazeAa() { Reset(); }
|
||||||
|
bool Prepare(VkDevice, PFN_vkGetDeviceProcAddr, int eye, VkImage source,
|
||||||
|
VkFormat source_format, VkImage destination,
|
||||||
|
VkFormat destination_format, std::uint32_t width,
|
||||||
|
std::uint32_t height, int mode=1,
|
||||||
|
const VkPhysicalDeviceMemoryProperties* memory=nullptr);
|
||||||
|
bool Record(VkCommandBuffer, int eye, float gaze_x, float gaze_y, int mode=1,
|
||||||
|
float radius=.22f);
|
||||||
|
void ForgetImage(VkImage);
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
template<class T> T Proc(const char* name) const {
|
||||||
|
return reinterpret_cast<T>(get_proc_(device_,name));
|
||||||
|
}
|
||||||
|
bool Initialize(VkDevice,PFN_vkGetDeviceProcAddr,VkFormat);
|
||||||
|
VkPipeline MakePipeline(VkRenderPass,const std::uint32_t*,std::size_t);
|
||||||
|
bool PrepareSmaa(int,const VkPhysicalDeviceMemoryProperties&,int mode);
|
||||||
|
void RecordSmaa(VkCommandBuffer,int,int,const void*,const VkRect2D&);
|
||||||
|
void RecordPass(VkCommandBuffer,int,VkPipeline,VkRenderPass,VkFramebuffer,
|
||||||
|
VkDescriptorSet,const void*,const VkRect2D&,bool);
|
||||||
|
void DestroySmaa();
|
||||||
|
struct Texture { VkImage image{}; VkDeviceMemory memory{}; VkImageView view{}; };
|
||||||
|
bool MakeTexture(Texture&,VkFormat,unsigned,unsigned,VkImageUsageFlags);
|
||||||
|
void FreeTexture(Texture&);
|
||||||
|
struct SmaaEye {
|
||||||
|
Texture textures[2];
|
||||||
|
VkFramebuffer framebuffers[2]{};
|
||||||
|
VkDescriptorSet descriptors[2]{};
|
||||||
|
std::uint32_t width=0,height=0;
|
||||||
|
VkImageView descriptor_source{};
|
||||||
|
};
|
||||||
|
struct Source { VkImage image{}; VkImageView view{}; VkFormat format{}; bool descriptor_ready=false; };
|
||||||
|
struct Target { VkImageView view{}; VkFramebuffer framebuffer{}; };
|
||||||
|
VkDevice device_{};
|
||||||
|
PFN_vkGetDeviceProcAddr get_proc_{};
|
||||||
|
VkFormat format_=VK_FORMAT_UNDEFINED;
|
||||||
|
VkDescriptorSetLayout descriptor_layout_{};
|
||||||
|
VkDescriptorPool descriptor_pool_{};
|
||||||
|
VkDescriptorSet descriptor_sets_[2]{};
|
||||||
|
VkPipelineLayout pipeline_layout_{};
|
||||||
|
VkPipeline pipeline_{};
|
||||||
|
VkRenderPass render_pass_{};
|
||||||
|
VkSampler sampler_{};
|
||||||
|
VkRenderPass smaa_pass_{};
|
||||||
|
VkPipeline smaa_pipelines_[2][3]{};
|
||||||
|
SmaaEye smaa_eyes_[2];
|
||||||
|
Texture smaa_luts_[2];
|
||||||
|
VkBuffer smaa_staging_{};
|
||||||
|
VkDeviceMemory smaa_staging_memory_{};
|
||||||
|
bool smaa_uploaded_=false;
|
||||||
|
Source sources_[2];
|
||||||
|
std::unordered_map<VkImage,Target> targets_;
|
||||||
|
VkImage destinations_[2]{};
|
||||||
|
std::uint32_t widths_[2]{},heights_[2]{};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_HEADSET_DETECTION_H_
|
||||||
|
#define MOCKTAIL_VR_HEADSET_DETECTION_H_
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// Set on the child process that answers DetectSteamVrHeadset. main() checks
|
||||||
|
// it first and returns RunHeadsetProbeChild() without starting Roblox.
|
||||||
|
inline constexpr const char* kHeadsetProbeEnvironment =
|
||||||
|
"MOCKTAIL_VR_HEADSET_PROBE";
|
||||||
|
|
||||||
|
struct HeadsetDetection {
|
||||||
|
bool headset = false;
|
||||||
|
std::string reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
// vr.mode: auto. True only when this user's SteamVR is running and its OpenXR
|
||||||
|
// runtime reports a head-mounted display. SteamVR is never started. The
|
||||||
|
// runtime is asked from a child process (`self_executable` with
|
||||||
|
// kHeadsetProbeEnvironment set) so its libraries never load into this one
|
||||||
|
// before the VR backend arms.
|
||||||
|
HeadsetDetection DetectSteamVrHeadset(
|
||||||
|
const std::string& self_executable,
|
||||||
|
std::chrono::milliseconds timeout = std::chrono::seconds(5),
|
||||||
|
const std::string& proc_root = "/proc");
|
||||||
|
|
||||||
|
// The child side: probes the OpenXR runtime in XR_RUNTIME_JSON and returns
|
||||||
|
// the probe's exit code (0 when a headset is available).
|
||||||
|
int RunHeadsetProbeChild();
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_HEADSET_DETECTION_H_
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
struct NativeProjectionSample {
|
||||||
|
// The actual engine camera projection before temporal jitter, row-major.
|
||||||
|
// This is read from the native camera rather than rebuilt from tracking FOV.
|
||||||
|
float projection[16] = {};
|
||||||
|
// The full world-view-projection used by the engine, including player
|
||||||
|
// locomotion. Supplied by the separately verified native combined getter.
|
||||||
|
float view_projection[16] = {};
|
||||||
|
float jitter_pixels[2] = {};
|
||||||
|
std::uint32_t render_width = 0;
|
||||||
|
std::uint32_t render_height = 0;
|
||||||
|
float camera_near = 0.f;
|
||||||
|
float camera_far = 0.f;
|
||||||
|
std::uintptr_t camera_identity = 0;
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
bool depth_inverted = false;
|
||||||
|
bool depth_infinite = false;
|
||||||
|
bool jitter_applied = false;
|
||||||
|
bool view_projection_valid = false;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// True only when the exact native per-eye projection function was verified and
|
||||||
|
// hooked. Unsupported clients retain their original projections.
|
||||||
|
bool NativeProjectionBridgeReady();
|
||||||
|
// Copies only a projection from the requested frame. A stale or unsupported
|
||||||
|
// camera never supplies temporal reconstruction parameters.
|
||||||
|
bool ReadNativeProjectionSample(int eye, std::uint64_t expected_frame,
|
||||||
|
NativeProjectionSample* sample);
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_OPENXR_BACKEND_H_
|
||||||
|
#define MOCKTAIL_VR_OPENXR_BACKEND_H_
|
||||||
|
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "mocktail/status.h"
|
||||||
|
#include "mocktail/vr/xr_foveation_maps.h"
|
||||||
|
#include "mocktail/vr/gaze_aa.h"
|
||||||
|
#include "mocktail/vr/fsr_upscaler.h"
|
||||||
|
#include "mocktail/vr/fsr3_scene_capture.h"
|
||||||
|
#ifdef MOCKTAIL_ENABLE_FSR3
|
||||||
|
#include "mocktail/vr/fsr3_upscaler.h"
|
||||||
|
#include "mocktail/vr/fsr3_gaze_transport.h"
|
||||||
|
#endif
|
||||||
|
#include "mocktail/vr/virtual_keyboard.h"
|
||||||
|
#include "mocktail/vr/vr_mirror.h"
|
||||||
|
#include "mocktail/vr/xr_actions.h"
|
||||||
|
#include "mocktail/vr/xr_controller.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
namespace internal {
|
||||||
|
// Deferral is safe only when the guest requested exactly one unprotected queue.
|
||||||
|
bool SingleUnprotectedVulkanQueue(const VkDeviceCreateInfo* info);
|
||||||
|
// Frame's native Video slider stores a per-eye pixel dimension per app.
|
||||||
|
std::uint32_t SteamFrameEyeResolution(std::string_view settings,
|
||||||
|
std::string_view steam_app_id);
|
||||||
|
|
||||||
|
// Swapchain format for guest eyes of `eye` given the runtime's formats:
|
||||||
|
// `eye` itself, else its UNORM/SRGB sibling (a raw byte copy, since the guest
|
||||||
|
// writes sRGB-encoded bytes either way), else the other channel order with
|
||||||
|
// the same sRGB-ness (a blit that only swizzles). VK_FORMAT_UNDEFINED when
|
||||||
|
// only a gamma-converting blit remains.
|
||||||
|
VkFormat PreferredEyeSwapchainFormat(VkFormat eye,
|
||||||
|
const std::vector<std::int64_t>& available);
|
||||||
|
// Consume binary present dependencies exactly once. On success a later present
|
||||||
|
// must omit them. VK_NOT_READY means nothing was consumed and the guest's
|
||||||
|
// present proceeds unchanged; any other failure consumed them (or lost the
|
||||||
|
// device) and must be propagated, not retried.
|
||||||
|
VkResult WaitForPresentDependencies(VkDevice device, VkQueue queue,
|
||||||
|
const VkPresentInfoKHR& present,
|
||||||
|
VkFence fence, PFN_vkResetFences reset,
|
||||||
|
PFN_vkQueueSubmit submit,
|
||||||
|
PFN_vkWaitForFences wait);
|
||||||
|
// Hands binary present dependencies to the GPU instead: one empty batch on
|
||||||
|
// `queue` waits on them and signals `signal`, which the present waits on
|
||||||
|
// instead. Work submitted to `queue` afterwards is ordered after the waits.
|
||||||
|
// The CPU never blocks. VK_NOT_READY means nothing was consumed.
|
||||||
|
VkResult ChainPresentDependencies(VkDevice device, VkQueue queue,
|
||||||
|
const VkPresentInfoKHR& present,
|
||||||
|
VkSemaphore signal, PFN_vkQueueSubmit submit);
|
||||||
|
// Makes the Vulkan adapter serialize queue calls for the rest of the process.
|
||||||
|
// Called when a backend arms; the adapter library may load before or after.
|
||||||
|
void RequestVulkanQueueSharing();
|
||||||
|
} // namespace internal
|
||||||
|
|
||||||
|
// Resolved --vr output mode. kXrOutput drives the real Roblox stereo images
|
||||||
|
// into an OpenXR projection layer; kNativeOnly keeps the diagnostic native
|
||||||
|
// stereo prototype without any runtime requirement. Selected through
|
||||||
|
// MOCKTAIL_VR_BACKEND ("xr" default, "native" diagnostic).
|
||||||
|
enum class VrBackendMode {
|
||||||
|
kDisabled,
|
||||||
|
kNativeOnly,
|
||||||
|
kXrOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explicit XR_RUNTIME_JSON wins. Runtime preference can select SteamVR (ALVR),
|
||||||
|
// WiVRn, or the registered OpenXR runtime without changing system registration.
|
||||||
|
std::string SelectVrRuntimeManifest(const std::string& explicit_manifest,
|
||||||
|
const std::vector<std::string>& candidates);
|
||||||
|
std::vector<std::string> VrRuntimeManifestCandidates(
|
||||||
|
const std::string &preference, const std::string &home,
|
||||||
|
const std::string &config_home, const std::string &config_dirs);
|
||||||
|
struct VrRuntimeDiscovery {
|
||||||
|
std::vector<std::string> candidates;
|
||||||
|
std::string reason;
|
||||||
|
};
|
||||||
|
VrRuntimeDiscovery DiscoverVrRuntime(
|
||||||
|
const std::string& preference, const std::string& home,
|
||||||
|
const std::string& config_home, const std::string& config_dirs,
|
||||||
|
const std::string& runtime_dir, const std::string& proc_root = "/proc");
|
||||||
|
std::string VrRuntimeUnavailableHint(bool user_selected, const std::string& manifest);
|
||||||
|
// SteamVR's OpenXR manifest when this user's vrserver is running, else empty.
|
||||||
|
std::string RunningSteamVrManifest(const std::string& proc_root = "/proc");
|
||||||
|
VrBackendMode ResolveVrBackendMode(bool vr_enabled);
|
||||||
|
const char* VrBackendModeName(VrBackendMode mode);
|
||||||
|
|
||||||
|
// One deterministic head pose sample. Position is metres, orientation is a
|
||||||
|
// quaternion (x, y, z, w) in the OpenXR LOCAL reference space.
|
||||||
|
// VrHandPose (tracked controller hands) is defined in xr_controller.h.
|
||||||
|
struct ScriptedPoseSample {
|
||||||
|
float position[3] = {0.f, 0.f, 0.f};
|
||||||
|
float orientation[4] = {0.f, 0.f, 0.f, 1.f};
|
||||||
|
float eye_offset[2][3] = {}; // metres in head space
|
||||||
|
float eye_fov[2][4] = {}; // left, right, up, down angles
|
||||||
|
float projection_jitter_pixels[2] = {}; // raster displacement: right/down
|
||||||
|
std::uint32_t projection_render_width = 0, projection_render_height = 0;
|
||||||
|
bool projection_jitter_valid = false;
|
||||||
|
// Index 0 = left, 1 = right (Roblox UserCFrame LeftHand/RightHand).
|
||||||
|
VrHandPose hands[2];
|
||||||
|
float floor_position[3] = {};
|
||||||
|
float floor_orientation[4] = {0.f, 0.f, 0.f, 1.f};
|
||||||
|
bool floor_valid = false;
|
||||||
|
// Verified DebugDeviceVR input channels, published with the same pose.
|
||||||
|
float controller_channels[28] = {};
|
||||||
|
bool controllers_connected = false;
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Production OpenXR backend for the native stereo prototype.
|
||||||
|
//
|
||||||
|
// Lifecycle: main arms the backend before Roblox startup (xrCreateInstance
|
||||||
|
// and xrGetSystem fail fast with a clear message when no runtime exists).
|
||||||
|
// The Vulkan adapter delegates the guest's vkCreateInstance/vkCreateDevice
|
||||||
|
// through xrCreateVulkanInstanceKHR/xrCreateVulkanDeviceKHR so the runtime's
|
||||||
|
// extension and GPU requirements are satisfied by the very device Roblox
|
||||||
|
// renders with. The session is created on that device; per-frame XR work runs
|
||||||
|
// on the render thread from the adapter's host-present notification:
|
||||||
|
// xrEndFrame(N) with the two eye images of frame N copied into the
|
||||||
|
// projection swapchains, then xrWaitFrame/xrBeginFrame/xrLocateViews for
|
||||||
|
// frame N+1 and publishing the scripted/runtime head pose that the device
|
||||||
|
// bridge injects into Roblox before its eye passes run.
|
||||||
|
//
|
||||||
|
// EGL/OpenGL ES uses XR_MNDX_egl_enable on the guest SDL context and GPU
|
||||||
|
// framebuffer blits into GLES swapchain textures. It shares the same frame
|
||||||
|
// scheduling, tracking, pose application and session recovery below.
|
||||||
|
//
|
||||||
|
// Eye image provenance is recorded in the adapter during the guest eye
|
||||||
|
// initializer (owner = DebugDeviceVR object) and bound to an eye index only
|
||||||
|
// through the real per-frame eye-getter request plus the actual render-pass
|
||||||
|
// attachment, mirroring the validated evidence-layer semantics. Creation
|
||||||
|
// order or size alone never selects an eye.
|
||||||
|
//
|
||||||
|
// All public entry points are exception-free at their boundary; the
|
||||||
|
// implementation is compiled with exceptions like openxr_preview.cc and
|
||||||
|
// translates failures into Status/log outcomes.
|
||||||
|
class GlesTransport;
|
||||||
|
enum class VrGraphicsApi { kVulkan, kOpenGles };
|
||||||
|
|
||||||
|
class OpenXrBackend final {
|
||||||
|
public:
|
||||||
|
OpenXrBackend() = default;
|
||||||
|
~OpenXrBackend();
|
||||||
|
OpenXrBackend(const OpenXrBackend&) = delete;
|
||||||
|
OpenXrBackend& operator=(const OpenXrBackend&) = delete;
|
||||||
|
|
||||||
|
// Claims the process-wide backend slot and creates XrInstance/system.
|
||||||
|
Status Arm(VrGraphicsApi graphics_api = VrGraphicsApi::kVulkan);
|
||||||
|
// EGL context must be current on the calling thread. Resolver bypasses guest
|
||||||
|
// hooks.
|
||||||
|
Status AttachGlesContext(void *display, void *config, void *context,
|
||||||
|
void *egl_get_proc, void *(*resolve)(const char *));
|
||||||
|
void DetachGlesContext(void *context);
|
||||||
|
void NoteGlesPresent();
|
||||||
|
void *WrapGlesProcAddress(const char *name, void *raw);
|
||||||
|
// Idempotent full teardown (XR resources first, then the instance).
|
||||||
|
void Disarm();
|
||||||
|
bool armed() const { return armed_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
|
// Adapter delegation. Return false when the backend does not own creation
|
||||||
|
// (caller falls back to the plain host loader path).
|
||||||
|
bool CreateVulkanInstance(const VkInstanceCreateInfo* create_info,
|
||||||
|
const VkAllocationCallbacks* allocator,
|
||||||
|
VkInstance* instance, VkResult* result);
|
||||||
|
bool CreateVulkanDevice(VkPhysicalDevice physical_device,
|
||||||
|
const VkDeviceCreateInfo* create_info,
|
||||||
|
const VkAllocationCallbacks* allocator,
|
||||||
|
VkDevice* device, VkResult* result);
|
||||||
|
// True transfers native device destruction to this backend.
|
||||||
|
bool NoteDeviceDestroyed(VkDevice device);
|
||||||
|
bool FoveationEnabled(VkDevice device) const;
|
||||||
|
VkImageView FoveationViewForFramebuffer(VkDevice device, VkFramebuffer framebuffer);
|
||||||
|
bool FoveationFocus(VkDevice device,VkFramebuffer framebuffer,float* x,float* y,std::uint64_t* frame,int* eye,int* level);
|
||||||
|
bool FoveationSnapshot(VkDevice device,float* centers,float* radius,std::uint64_t* frame,int* level);
|
||||||
|
// A scene intermediate may be shared between eyes. Resolve it from the live
|
||||||
|
// eye getter, never from its framebuffer handle or dimensions alone.
|
||||||
|
bool FoveationRenderFocus(VkDevice device, VkFramebuffer framebuffer,
|
||||||
|
std::uint32_t scene_width, std::uint32_t scene_height, float* x, float* y,
|
||||||
|
float* radius, std::uint64_t* frame, int* eye, int* level);
|
||||||
|
bool FoveationHostCopyEnabled(VkDevice device) const;
|
||||||
|
bool Fsr3InputsAvailable() const;
|
||||||
|
bool Fsr3InputsEnabled() const;
|
||||||
|
void CaptureFsr3Scene(VkDevice, VkCommandBuffer, const TemporalSceneInputs&);
|
||||||
|
void CaptureFsr3Motion(VkDevice, VkCommandBuffer, const TemporalMotionInputs&);
|
||||||
|
VkExtent2D FoveationMapExtent(VkDevice device) const;
|
||||||
|
// True transfers native instance destruction to this backend.
|
||||||
|
bool NoteInstanceDestroyed(VkInstance instance);
|
||||||
|
void NoteQueue(VkDevice device, VkQueue queue, std::uint32_t family,
|
||||||
|
std::uint32_t index);
|
||||||
|
std::vector<std::uint32_t> DesktopQueueFamilies(VkDevice device) const;
|
||||||
|
void RecordCommandPool(VkDevice device, VkCommandPool pool,
|
||||||
|
std::uint32_t family);
|
||||||
|
void RecordCommandBuffers(VkDevice device, VkCommandPool pool,
|
||||||
|
std::uint32_t count,
|
||||||
|
const VkCommandBuffer* buffers);
|
||||||
|
void ForgetCommandBuffers(std::uint32_t count,
|
||||||
|
const VkCommandBuffer* buffers);
|
||||||
|
void ForgetCommandPool(VkCommandPool pool);
|
||||||
|
void ResetCommandPool(VkCommandPool pool);
|
||||||
|
void ResetCommandBuffer(VkCommandBuffer buffer);
|
||||||
|
void NoteCommandBuffersSubmitted(VkQueue queue, std::uint32_t count,
|
||||||
|
const VkCommandBuffer* buffers);
|
||||||
|
VkImageLayout NormalizeDesktopBarrier(VkCommandBuffer command, VkImage image,
|
||||||
|
std::uint32_t* source,
|
||||||
|
std::uint32_t* destination,
|
||||||
|
VkImageLayout* old_layout,
|
||||||
|
VkImageLayout new_layout);
|
||||||
|
|
||||||
|
void NormalizeDesktopBarriers(VkCommandBuffer command, std::uint32_t count,
|
||||||
|
VkImageMemoryBarrier* barriers);
|
||||||
|
void NormalizeDesktopBarriers(VkCommandBuffer command, std::uint32_t count,
|
||||||
|
VkImageMemoryBarrier2* barriers);
|
||||||
|
|
||||||
|
// Resource provenance recording (adapter wrappers).
|
||||||
|
bool WantsResourceRecords() const {
|
||||||
|
return recording_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
void RecordImage(VkDevice device, VkImage image,
|
||||||
|
const VkImageCreateInfo* info);
|
||||||
|
void RecordImageView(VkImageView view, VkImage image);
|
||||||
|
void RecordFramebuffer(VkFramebuffer framebuffer,
|
||||||
|
const VkFramebufferCreateInfo* info);
|
||||||
|
void RecordRenderPass(VkRenderPass render_pass,
|
||||||
|
const VkRenderPassCreateInfo* info);
|
||||||
|
void NoteDestroyImage(VkImage image);
|
||||||
|
void NoteDestroyImageView(VkImageView view);
|
||||||
|
void NoteDestroyFramebuffer(VkFramebuffer framebuffer);
|
||||||
|
void NoteRenderPassBegin(VkCommandBuffer command_buffer,
|
||||||
|
const VkRenderPassBeginInfo* info);
|
||||||
|
bool EyeBindingComplete() const {
|
||||||
|
return eyes_bound_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render-thread frame cycle at host present begin.
|
||||||
|
void NoteHostPresent(VkQueue queue, VkDevice device);
|
||||||
|
// VK_NOT_READY leaves the present untouched until eye submission provenance
|
||||||
|
// is known. VK_SUCCESS means its waits were consumed on the graphics queue,
|
||||||
|
// so the eye copies that follow there are ordered after them. The present
|
||||||
|
// must then wait on *ready instead (none when it is VK_NULL_HANDLE).
|
||||||
|
VkResult PrepareHostPresent(VkQueue queue, VkDevice device,
|
||||||
|
const VkPresentInfoKHR* info, VkSemaphore* ready);
|
||||||
|
void RecordDesktopSwapchain(VkDevice device, VkSwapchainKHR swapchain,
|
||||||
|
const VkSwapchainCreateInfoKHR* info,
|
||||||
|
const VkImage* images, unsigned count,
|
||||||
|
bool converted_to_concurrent = false);
|
||||||
|
// On success the original present semaphores have been consumed by the
|
||||||
|
// mirror blit on the GPU. The caller must present waiting only on *ready,
|
||||||
|
// which the blit signals. The CPU never waits for the guest's frame here.
|
||||||
|
VkResult MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* info,
|
||||||
|
VkSemaphore* ready);
|
||||||
|
|
||||||
|
// Pose published for the frame that is about to render. The device bridge
|
||||||
|
// reads it once per frame on the render thread and injects it into the
|
||||||
|
// guest. valid=false until the XR session located a usable head pose.
|
||||||
|
// hands[0]=left, hands[1]=right carry this frame's tracked controller grip
|
||||||
|
// poses (metres, LOCAL space) when a controller layer is active.
|
||||||
|
ScriptedPoseSample PublishedHeadPose() const;
|
||||||
|
bool RecommendedEyeExtent(std::uint32_t* width, std::uint32_t* height) const;
|
||||||
|
// Render-thread boundary only: rebuild XR targets when Steam's settled
|
||||||
|
// resolution changes. The guest then reruns its verified eye initializer.
|
||||||
|
bool UpdateEyeResolution();
|
||||||
|
void NotePoseApplied(void* owner, std::uint64_t frame);
|
||||||
|
|
||||||
|
// Controller input delivery, consumed by the window-thread input runtime
|
||||||
|
// through the C ABI in xr_controller_abi.h. TakeControllerDelivery pops one
|
||||||
|
// batch produced by the once-per-frame action sync; it returns false when
|
||||||
|
// no controller layer is active or nothing is queued. The snapshot exposes
|
||||||
|
// the current levels for overflow resync. Haptics requests are thread-safe.
|
||||||
|
bool TakeControllerDelivery(ControllerDelivery* out);
|
||||||
|
ControllerSnapshot ControllerSnapshotForFrame() const;
|
||||||
|
void RequestControllerHaptics(int hand, float amplitude,
|
||||||
|
std::uint64_t duration_ns, float frequency_hz);
|
||||||
|
|
||||||
|
void StopControllerHaptics(int hand);
|
||||||
|
|
||||||
|
// Diagnostics.
|
||||||
|
std::uint64_t submitted_frames() const {
|
||||||
|
return submitted_frames_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t skipped_frames() const {
|
||||||
|
return skipped_frames_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
bool session_running() const {
|
||||||
|
return session_running_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::string runtime_name() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
VkImageLayout NormalizeDesktopBarrierLocked(std::uint32_t family,
|
||||||
|
VkImage image, std::uint32_t* source, std::uint32_t* destination,
|
||||||
|
VkImageLayout* old_layout, VkImageLayout new_layout);
|
||||||
|
bool FoveationSnapshotLocked(VkDevice,float*,float*,std::uint64_t*,int*);
|
||||||
|
int FoveationSceneEyeLocked(VkDevice, std::uint32_t, std::uint32_t) const;
|
||||||
|
friend struct OpenXrBackendTestAccess;
|
||||||
|
struct ImageRecord {
|
||||||
|
VkImage image = VK_NULL_HANDLE;
|
||||||
|
VkDevice device = VK_NULL_HANDLE;
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
VkFormat format = VK_FORMAT_UNDEFINED;
|
||||||
|
VkImageUsageFlags usage = 0;
|
||||||
|
VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT;
|
||||||
|
void* owner = nullptr;
|
||||||
|
std::uint64_t order = 0;
|
||||||
|
};
|
||||||
|
struct EyeBinding {
|
||||||
|
bool valid = false;
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
VkImage image = VK_NULL_HANDLE;
|
||||||
|
VkFramebuffer framebuffer = VK_NULL_HANDLE;
|
||||||
|
void* guest_framebuffer = nullptr;
|
||||||
|
VkImageLayout final_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
||||||
|
void* owner = nullptr;
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
VkFormat format = VK_FORMAT_UNDEFINED;
|
||||||
|
};
|
||||||
|
struct EyeSwapchain {
|
||||||
|
void* swapchain = nullptr; // XrSwapchain (opaque to keep the header XR-free)
|
||||||
|
std::vector<VkImage> images;
|
||||||
|
std::vector<std::uint32_t> gl_images;
|
||||||
|
std::uint32_t acquired_index = 0;
|
||||||
|
bool image_acquired = false;
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
VkFormat format = VK_FORMAT_UNDEFINED;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool InitializeSessionLocked(VkPhysicalDevice physical_device,
|
||||||
|
VkDevice device,
|
||||||
|
const VkDeviceCreateInfo* create_info,
|
||||||
|
std::string* error);
|
||||||
|
// Session-owned controller action layer (poses, buttons, axes, haptics).
|
||||||
|
// Created at instance level, attached per session, detached on teardown so
|
||||||
|
// a replacement session never reuses stale spaces.
|
||||||
|
XrActions actions_;
|
||||||
|
bool actions_created_ = false;
|
||||||
|
// Shared by action sync, snapshots, haptics and lifecycle operations.
|
||||||
|
// Acquired after mutex_ on the render thread, but never held across
|
||||||
|
// xrWaitFrame; the input thread takes only this controller lock.
|
||||||
|
mutable std::mutex controller_mutex_;
|
||||||
|
// Syncs actions once per frame at the predicted display time and folds the
|
||||||
|
// resulting hand poses into published_pose_ before the guest renders.
|
||||||
|
void SyncControllersLocked(std::uint64_t predicted_display_time);
|
||||||
|
bool InitializeGlesSessionLocked(std::string *error);
|
||||||
|
bool InitializeSessionResourcesLocked(std::string *error);
|
||||||
|
bool CopyGlesEyesIntoSwapchains();
|
||||||
|
bool EnsureXrInstanceLocked(std::string* error = nullptr);
|
||||||
|
void DestroyXrInstanceLocked();
|
||||||
|
void DisarmLocked();
|
||||||
|
void* ResolveFunction(const char* name) const;
|
||||||
|
void SetupDeviceAfterCreationLocked(VkPhysicalDevice physical_device,
|
||||||
|
VkDevice device,
|
||||||
|
const VkDeviceCreateInfo* create_info);
|
||||||
|
void TeardownSessionLocked(const char* reason, bool preserve_device = false);
|
||||||
|
void HandleSessionState(int state);
|
||||||
|
void HandleReferenceSpaceChange(std::int64_t change_time);
|
||||||
|
bool RecoverSessionLocked(std::uint64_t now_ns);
|
||||||
|
// False only when the runtime names a GPU other than the guest device's.
|
||||||
|
bool RuntimeUsesGuestGpuLocked();
|
||||||
|
void PollSessionEvents();
|
||||||
|
bool SupportsPresentQueueLocked(VkQueue queue) const;
|
||||||
|
VkQueue FrameQueueForPresentLocked(VkQueue queue) const;
|
||||||
|
VkQueue GraphicsQueueForPresentLocked(VkQueue present) const;
|
||||||
|
bool unsupported_present_queue_logged_ = false;
|
||||||
|
// Drops the pose/images of the frame currently in flight without touching
|
||||||
|
// guest ownership, so the next xrEndFrame submits no layer instead of an
|
||||||
|
// image paired with a pose that no longer describes the same origin.
|
||||||
|
void InvalidateInFlightPose(const char* reason);
|
||||||
|
void RunFrameCycle(VkQueue queue, VkDevice device);
|
||||||
|
// Returns true when the backend owns this present but ran no XR frame, so
|
||||||
|
// the caller paces the guest itself.
|
||||||
|
bool NoteHostPresentLocked(VkQueue queue, VkDevice device);
|
||||||
|
bool NeedsIdlePacingLocked() const;
|
||||||
|
bool CreateSwapchains(std::string* error);
|
||||||
|
// Recreates the eye swapchains once in the format that copies the guest's
|
||||||
|
// eye bytes without a gamma change (see PreferredEyeSwapchainFormat).
|
||||||
|
void MatchSwapchainFormatToEyesLocked();
|
||||||
|
void DestroySwapchains();
|
||||||
|
void ResetFsr3Locked();
|
||||||
|
bool EnsureCopyResources(VkDevice device, std::uint32_t queue_family,
|
||||||
|
std::string* error);
|
||||||
|
void DestroyCopyResources();
|
||||||
|
// Records both eye copies (and, when the mirror is enabled, both staging
|
||||||
|
// readbacks) into one command buffer and submits it once. Only the shared
|
||||||
|
// memory mirror waits for it; the headset path leaves it to the GPU.
|
||||||
|
bool CopyEyesIntoSwapchains(VkQueue queue);
|
||||||
|
void RecordEyeCopiesLocked(VkCommandBuffer command, const VkImage source[2],
|
||||||
|
const VkImage destination[2]);
|
||||||
|
void RecordFxaaEyesLocked(VkCommandBuffer command);
|
||||||
|
// Per-frame GPU work (desktop mirror, eye copies) is submitted without a CPU
|
||||||
|
// wait. A slot is reused only after its fence signalled; its images let a
|
||||||
|
// guest destroy wait for the submissions that still read them.
|
||||||
|
struct AsyncSubmission {
|
||||||
|
VkCommandBuffer command = VK_NULL_HANDLE;
|
||||||
|
VkFence fence = VK_NULL_HANDLE;
|
||||||
|
bool pending = false;
|
||||||
|
bool eye_resources = false;
|
||||||
|
std::uint64_t serial = 0; // bumped per submission
|
||||||
|
std::vector<VkImage> images;
|
||||||
|
};
|
||||||
|
// The shared-memory mirror's readback from the previous frame copy. It is
|
||||||
|
// published at the next copy, when the GPU has long finished it, so neither
|
||||||
|
// the headset nor the mirror makes the CPU wait for the frame.
|
||||||
|
struct MirrorReadback {
|
||||||
|
AsyncSubmission* slot = nullptr;
|
||||||
|
std::uint64_t serial = 0;
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
VkFormat format = VK_FORMAT_UNDEFINED;
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
};
|
||||||
|
void PublishMirrorReadbackLocked();
|
||||||
|
// vr.anti_aliasing: fxaa. The eye copy becomes one FXAA draw per eye into
|
||||||
|
// the runtime's swapchain image. Anything it cannot set up falls back to
|
||||||
|
// the plain copy, which never loses headset output.
|
||||||
|
struct FxaaState {
|
||||||
|
VkFormat target_format = VK_FORMAT_UNDEFINED;
|
||||||
|
VkShaderModule vertex = VK_NULL_HANDLE;
|
||||||
|
VkShaderModule fragment = VK_NULL_HANDLE;
|
||||||
|
VkSampler sampler = VK_NULL_HANDLE;
|
||||||
|
VkDescriptorSetLayout set_layout = VK_NULL_HANDLE;
|
||||||
|
VkDescriptorPool pool = VK_NULL_HANDLE;
|
||||||
|
VkDescriptorSet sets[2] = {};
|
||||||
|
VkPipelineLayout layout = VK_NULL_HANDLE;
|
||||||
|
VkRenderPass render_pass = VK_NULL_HANDLE;
|
||||||
|
VkPipeline pipeline = VK_NULL_HANDLE;
|
||||||
|
// The eye image each set samples, and its view.
|
||||||
|
VkImage sources[2] = {};
|
||||||
|
VkImageView source_views[2] = {};
|
||||||
|
// Per eye, one view and framebuffer per runtime swapchain image.
|
||||||
|
std::vector<VkImageView> target_views[2];
|
||||||
|
std::vector<VkFramebuffer> framebuffers[2];
|
||||||
|
bool failed = false;
|
||||||
|
};
|
||||||
|
bool FxaaEnabled() const;
|
||||||
|
// Pipeline, targets and eye bindings for this frame; false means copy.
|
||||||
|
bool PrepareFxaaLocked();
|
||||||
|
bool CreateFxaaPipelineLocked(VkFormat target_format);
|
||||||
|
void DestroyFxaaTargetsLocked();
|
||||||
|
void ForgetFxaaSourceLocked(VkImage image);
|
||||||
|
void DestroyFxaaLocked();
|
||||||
|
// The next slot, recorded-ready, or nullptr when its previous submission
|
||||||
|
// did not finish in time (the device is hung or lost).
|
||||||
|
AsyncSubmission* NextAsyncSubmissionLocked();
|
||||||
|
VkResult SubmitAsyncLocked(VkQueue queue, AsyncSubmission* slot,
|
||||||
|
const VkSubmitInfo& submit);
|
||||||
|
// Waits for pending submissions reading `image`, or all of them for
|
||||||
|
// VK_NULL_HANDLE.
|
||||||
|
void WaitAsyncSubmissionsLocked(VkImage image);
|
||||||
|
// AA/FSR descriptors and history are shared between eye copies. Wait only
|
||||||
|
// for their previous users, never the current guest-present dependency.
|
||||||
|
void WaitEyeResourcesLocked();
|
||||||
|
// Semaphore the present of `image_index` waits on, created on first use.
|
||||||
|
enum class PresentStep { kMirror, kPrepare };
|
||||||
|
VkSemaphore PresentReadySemaphoreLocked(VkSwapchainKHR swapchain,
|
||||||
|
std::uint32_t image_index,
|
||||||
|
PresentStep step);
|
||||||
|
void DestroyRetiredSemaphoresLocked();
|
||||||
|
// In-headset keyboard: show/hide from the host's TextBox request, pointer
|
||||||
|
// input, and trigger masking. Runs inside the controller sync.
|
||||||
|
void UpdateVirtualKeyboardLocked();
|
||||||
|
// Rasterizes and uploads the panel when it changed. Returns true when the
|
||||||
|
// keyboard swapchain holds a released image ready for a quad layer.
|
||||||
|
bool PrepareVirtualKeyboardLayerLocked(VkQueue queue);
|
||||||
|
bool UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image);
|
||||||
|
void DestroyVirtualKeyboardStaging();
|
||||||
|
void DestroyVirtualKeyboardResources();
|
||||||
|
bool CaptureEyeEvidence(VkQueue queue, int eye);
|
||||||
|
ScriptedPoseSample ComputeScriptedPose(std::uint64_t frame) const;
|
||||||
|
void EnsureMirrorForExtent(std::uint32_t width, std::uint32_t height);
|
||||||
|
MirrorVkProcs MirrorProcs() const;
|
||||||
|
|
||||||
|
VrGraphicsApi graphics_api_ = VrGraphicsApi::kVulkan;
|
||||||
|
GlesTransport *gles_ = nullptr;
|
||||||
|
std::uint64_t gl_min_version_ = 0;
|
||||||
|
std::uint64_t gl_max_version_ = 0;
|
||||||
|
|
||||||
|
// Held for a whole XR frame cycle and by every path that can destroy the
|
||||||
|
// session; always taken before mutex_.
|
||||||
|
std::mutex frame_mutex_;
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
// The frame cycle's hold on mutex_, so it can release it around
|
||||||
|
// xrWaitFrame. Only set while NoteHostPresent runs the cycle.
|
||||||
|
std::unique_lock<std::mutex>* state_lock_ = nullptr;
|
||||||
|
std::atomic<bool> armed_{false};
|
||||||
|
std::atomic<bool> recording_{false};
|
||||||
|
std::atomic<bool> eyes_bound_{false};
|
||||||
|
std::atomic<bool> session_running_{false};
|
||||||
|
std::atomic<std::uint64_t> submitted_frames_{0};
|
||||||
|
std::atomic<std::uint64_t> skipped_frames_{0};
|
||||||
|
|
||||||
|
// XR handles are stored as opaque pointers in the header; the implementation
|
||||||
|
// casts them back. This keeps vulkan-only consumers free of OpenXR headers.
|
||||||
|
void* xr_instance_ = nullptr;
|
||||||
|
std::uint64_t xr_system_ = 0;
|
||||||
|
void* xr_session_ = nullptr;
|
||||||
|
void* xr_local_space_ = nullptr;
|
||||||
|
void* xr_view_space_ = nullptr;
|
||||||
|
void* xr_stage_space_ = nullptr;
|
||||||
|
std::uint32_t xr_eye_width_[2] = {};
|
||||||
|
std::uint32_t xr_eye_height_[2] = {};
|
||||||
|
std::uint32_t xr_max_eye_width_[2] = {};
|
||||||
|
std::uint32_t xr_max_eye_height_[2] = {};
|
||||||
|
std::uint32_t resize_candidate_ = 0;
|
||||||
|
std::uint64_t resize_candidate_since_ = 0;
|
||||||
|
EyeSwapchain eye_swapchains_[2];
|
||||||
|
VirtualKeyboard keyboard_;
|
||||||
|
std::unique_ptr<VirtualKeyboardRasterizer> keyboard_raster_;
|
||||||
|
EyeSwapchain keyboard_swapchain_;
|
||||||
|
bool keyboard_swapchain_failed_ = false;
|
||||||
|
bool keyboard_image_ready_ = false;
|
||||||
|
std::uint64_t keyboard_uploaded_revision_ = 0;
|
||||||
|
// A new showKeyboard request may open the panel; Close disarms it until
|
||||||
|
// Roblox drops that request.
|
||||||
|
bool keyboard_armed_ = true;
|
||||||
|
pose::Pose keyboard_head_{};
|
||||||
|
bool keyboard_head_valid_ = false;
|
||||||
|
std::vector<std::uint8_t> keyboard_pixels_;
|
||||||
|
VkBuffer keyboard_staging_ = VK_NULL_HANDLE;
|
||||||
|
VkDeviceMemory keyboard_staging_memory_ = VK_NULL_HANDLE;
|
||||||
|
void* keyboard_staging_mapped_ = nullptr;
|
||||||
|
int xr_blend_mode_ = 0;
|
||||||
|
// Runtime name captured at xrCreateInstance so the bind-time diagnostics line
|
||||||
|
// can report it without re-entering runtime_name() under the held mutex.
|
||||||
|
std::string runtime_name_;
|
||||||
|
bool xr_instance_used_ = false;
|
||||||
|
// Libraries the runtime's first instance loaded; later instances load them
|
||||||
|
// with RTLD_DEEPBIND (see EnsureXrInstanceLocked).
|
||||||
|
std::vector<std::string> runtime_libraries_;
|
||||||
|
std::string user_manifest_;
|
||||||
|
std::string runtime_manifest_;
|
||||||
|
|
||||||
|
VkInstance vk_instance_ = VK_NULL_HANDLE;
|
||||||
|
std::uint32_t vk_instance_api_version_ = VK_API_VERSION_1_0;
|
||||||
|
bool fsr3_device_supported_ = false;
|
||||||
|
// One retained instance avoids invalid SteamVR ARM graphics-device caches
|
||||||
|
// during menu/game transitions. Only an identical creation request reuses it.
|
||||||
|
bool vk_instance_retained_ = false;
|
||||||
|
bool vk_device_retained_ = false;
|
||||||
|
bool foveation_runtime_available_ = false;
|
||||||
|
bool eye_gaze_available_ = false;
|
||||||
|
bool foveation_device_enabled_ = false;
|
||||||
|
bool foveation_host_copy_enabled_ = false;
|
||||||
|
bool foveation_used_in_frame_ = false;
|
||||||
|
XrFoveationMaps foveation_;
|
||||||
|
GazeAa gaze_aa_;
|
||||||
|
FsrUpscaler fsr_;
|
||||||
|
Fsr3SceneCapture fsr3_capture_;
|
||||||
|
#ifdef MOCKTAIL_ENABLE_FSR3
|
||||||
|
Fsr3Upscaler fsr3_;
|
||||||
|
Fsr3GazeTransport fsr3_transport_;
|
||||||
|
#endif
|
||||||
|
std::atomic<bool> fsr3_inputs_enabled_{false};
|
||||||
|
bool fsr3_failure_logged_ = false;
|
||||||
|
std::uint64_t fsr3_last_frame_ns_ = 0;
|
||||||
|
std::uint64_t fsr3_last_frame_ = 0;
|
||||||
|
bool fsr3_has_resources_ = false;
|
||||||
|
bool fsr3_capture_seen_[2]{};
|
||||||
|
float fsr3_gaze_radius_ = -1.f;
|
||||||
|
bool fsr3_contexts_configured_ = false;
|
||||||
|
bool fsr3_estimate_motion_ = true;
|
||||||
|
bool fsr3_depth_inverted_ = false, fsr3_depth_infinite_ = false;
|
||||||
|
std::uint64_t fsr3_camera_identity_[2]{};
|
||||||
|
float fsr3_camera_near_[2]{}, fsr3_camera_far_[2]{};
|
||||||
|
static constexpr int upscale_mode_ = 0;
|
||||||
|
FsrSettings fsr_settings_{};
|
||||||
|
bool fsr_failure_logged_ = false;
|
||||||
|
std::string guest_device_key_;
|
||||||
|
static std::string VulkanDeviceKey(const VkDeviceCreateInfo* info);
|
||||||
|
void ReleaseGuestRendererLocked();
|
||||||
|
unsigned guest_device_generation_ = 0;
|
||||||
|
bool defer_initial_session_ = false;
|
||||||
|
std::string guest_instance_key_;
|
||||||
|
VkDevice vk_device_ = VK_NULL_HANDLE;
|
||||||
|
VkPhysicalDevice vk_physical_device_ = VK_NULL_HANDLE;
|
||||||
|
std::uint32_t vk_queue_family_ = 0;
|
||||||
|
VkQueue vk_graphics_queue_ = VK_NULL_HANDLE;
|
||||||
|
std::vector<std::uint32_t> device_queue_families_;
|
||||||
|
bool single_guest_queue_ = false;
|
||||||
|
void* vk_loader_ = nullptr;
|
||||||
|
struct VkProcs;
|
||||||
|
VkProcs* vk_ = nullptr;
|
||||||
|
|
||||||
|
VkCommandPool copy_pool_ = VK_NULL_HANDLE;
|
||||||
|
// Synchronous one-off work: keyboard uploads, evidence, present fallback.
|
||||||
|
VkCommandBuffer copy_command_ = VK_NULL_HANDLE;
|
||||||
|
VkFence copy_fence_ = VK_NULL_HANDLE;
|
||||||
|
// Two submissions per frame, so three frames may be in flight.
|
||||||
|
static constexpr std::size_t kAsyncSubmissions = 6;
|
||||||
|
std::array<AsyncSubmission, kAsyncSubmissions> async_{};
|
||||||
|
std::size_t next_async_ = 0;
|
||||||
|
MirrorReadback mirror_readback_{};
|
||||||
|
FxaaState fxaa_{};
|
||||||
|
// The mirror's signal for this present, which PrepareHostPresent passes on.
|
||||||
|
VkSemaphore mirror_ready_ = VK_NULL_HANDLE;
|
||||||
|
// Semaphores of destroyed desktop swapchains. A present may still wait on
|
||||||
|
// one, so they are destroyed only once the device is idle.
|
||||||
|
std::vector<VkSemaphore> retired_semaphores_;
|
||||||
|
std::unordered_map<VkFormat, VkFormatFeatureFlags> blit_features_;
|
||||||
|
|
||||||
|
// Optional desktop two-eye mirror (MOCKTAIL_VR_MIRROR_SHM). Publishes the
|
||||||
|
// latest complete pair through shared memory; inert when unset.
|
||||||
|
VrMirror mirror_;
|
||||||
|
|
||||||
|
std::unordered_map<VkImage, ImageRecord> images_;
|
||||||
|
struct DesktopSwapchain {
|
||||||
|
VkExtent2D extent;
|
||||||
|
VkFormat format;
|
||||||
|
std::vector<VkImage> images;
|
||||||
|
VkSharingMode sharing_mode = VK_SHARING_MODE_EXCLUSIVE;
|
||||||
|
std::vector<std::uint32_t> families;
|
||||||
|
bool converted_to_concurrent = false;
|
||||||
|
// Two per image, see PresentReadySemaphoreLocked.
|
||||||
|
std::vector<VkSemaphore> ready;
|
||||||
|
};
|
||||||
|
std::unordered_map<VkSwapchainKHR, DesktopSwapchain> desktop_swapchains_;
|
||||||
|
std::unordered_map<VkImageView, VkImage> image_views_;
|
||||||
|
std::unordered_map<VkFramebuffer, std::vector<VkImageView>> framebuffer_views_;
|
||||||
|
std::unordered_map<VkFramebuffer, VkRenderPass> framebuffer_render_pass_;
|
||||||
|
std::unordered_map<VkRenderPass, std::vector<VkImageLayout>> render_pass_final_layouts_;
|
||||||
|
std::unordered_map<VkQueue, std::uint32_t> queue_families_;
|
||||||
|
struct CommandRecord {
|
||||||
|
VkCommandPool pool = VK_NULL_HANDLE;
|
||||||
|
std::uint32_t family = UINT32_MAX;
|
||||||
|
std::vector<std::pair<VkImage, std::uint64_t>> eye_images;
|
||||||
|
};
|
||||||
|
std::unordered_map<VkCommandPool, std::uint32_t> command_pool_families_;
|
||||||
|
std::unordered_map<VkCommandBuffer, CommandRecord> command_records_;
|
||||||
|
std::unordered_map<VkImage, VkQueue> eye_render_queues_;
|
||||||
|
std::unordered_map<VkImage, std::uint64_t> eye_render_frames_;
|
||||||
|
std::uint64_t image_order_ = 0;
|
||||||
|
EyeBinding eyes_[2];
|
||||||
|
|
||||||
|
// Frame cycle state (render thread only).
|
||||||
|
bool frame_open_ = false;
|
||||||
|
bool frame_should_render_ = false;
|
||||||
|
// A running session is hidden in SYNCHRONIZED; shouldRender still governs rendering.
|
||||||
|
bool visibility_lost_ = false;
|
||||||
|
bool session_recovery_pending_ = false;
|
||||||
|
std::uint64_t next_session_retry_ns_ = 0;
|
||||||
|
// Consecutive failed session recoveries, for backoff and for deciding when
|
||||||
|
// the XrInstance itself must be recreated (e.g. after a runtime restart).
|
||||||
|
std::uint32_t session_recovery_failures_ = 0;
|
||||||
|
bool xr_instance_stale_ = false;
|
||||||
|
bool present_sync_failure_logged_ = false;
|
||||||
|
bool gles_attach_failed_ = false;
|
||||||
|
// Runtime swapchain formats, and the format chosen once the guest eye
|
||||||
|
// format is known (VK_FORMAT_UNDEFINED until then).
|
||||||
|
std::vector<std::int64_t> xr_swapchain_formats_;
|
||||||
|
VkFormat eye_swapchain_format_ = VK_FORMAT_UNDEFINED;
|
||||||
|
VkFormat eye_format_checked_ = VK_FORMAT_UNDEFINED;
|
||||||
|
std::uint64_t frame_display_time_ = 0;
|
||||||
|
// MOCKTAIL_VR_UNPACED benchmark mode: guest presents before this monotonic
|
||||||
|
// time leave the open XR frame alone instead of blocking in xrWaitFrame.
|
||||||
|
std::uint64_t frame_due_ns_ = 0;
|
||||||
|
struct ViewState {
|
||||||
|
float position[3];
|
||||||
|
float orientation[4];
|
||||||
|
float fov[4]; // angleLeft, angleRight, angleUp, angleDown (radians)
|
||||||
|
bool valid;
|
||||||
|
};
|
||||||
|
ViewState frame_views_[2] = {};
|
||||||
|
struct PoseStorage {
|
||||||
|
float position[3] = {0.f, 0.f, 0.f};
|
||||||
|
float orientation[4] = {0.f, 0.f, 0.f, 1.f};
|
||||||
|
};
|
||||||
|
PoseStorage frame_view_poses_[2];
|
||||||
|
bool frame_views_valid_ = false;
|
||||||
|
// One-shot guard so a canted-view runtime reports the rejection reason once
|
||||||
|
// instead of once per frame per eye.
|
||||||
|
bool projection_rejection_logged_ = false;
|
||||||
|
mutable ScriptedPoseSample published_pose_;
|
||||||
|
std::uint64_t xr_frame_counter_ = 0;
|
||||||
|
// All begun XR frames, including an unworn/invisible headset.
|
||||||
|
std::uint64_t xr_begun_frames_ = 0;
|
||||||
|
std::unordered_map<void*, std::uint64_t> applied_poses_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process-wide single backend owned by main. Adapter hooks resolve these
|
||||||
|
// exported C symbols; they are inert while no backend is armed.
|
||||||
|
OpenXrBackend* ActiveVrBackend();
|
||||||
|
|
||||||
|
// Paths of the shared objects mapped into the process.
|
||||||
|
std::vector<std::string> LoadedSharedObjects();
|
||||||
|
// Paths mapped now that were not in `before`.
|
||||||
|
std::vector<std::string> SharedObjectsLoadedSince(
|
||||||
|
const std::vector<std::string>& before);
|
||||||
|
|
||||||
|
// Holds the runtime's libraries open with RTLD_DEEPBIND while the OpenXR
|
||||||
|
// loader loads the runtime, so its Vulkan calls bind to its own libvulkan
|
||||||
|
// dependency instead of the Vulkan adapter's exported hooks. Released after
|
||||||
|
// the loader holds its own reference, so the runtime still unloads with its
|
||||||
|
// instance.
|
||||||
|
class DeepBoundLibraries {
|
||||||
|
public:
|
||||||
|
explicit DeepBoundLibraries(const std::vector<std::string>& paths);
|
||||||
|
~DeepBoundLibraries();
|
||||||
|
DeepBoundLibraries(const DeepBoundLibraries&) = delete;
|
||||||
|
DeepBoundLibraries& operator=(const DeepBoundLibraries&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<void*> handles_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_OPENXR_BACKEND_H_
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_OPENXR_PREVIEW_H_
|
||||||
|
#define MOCKTAIL_VR_OPENXR_PREVIEW_H_
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
struct PreviewOptions {
|
||||||
|
// Zero keeps the scene open until the runtime or user requests exit.
|
||||||
|
std::uint32_t frame_limit = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PreviewResult {
|
||||||
|
bool success = false;
|
||||||
|
std::string message;
|
||||||
|
std::uint64_t frames = 0;
|
||||||
|
std::uint64_t rendered_frames = 0;
|
||||||
|
std::uint64_t tracked_frames = 0;
|
||||||
|
std::array<float, 3> head_position{};
|
||||||
|
std::array<float, 4> head_orientation{0, 0, 0, 1};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs a native Vulkan/OpenXR scene with a panel fixed in LOCAL space. The
|
||||||
|
// compositor projects it into both eyes. Roblox images/input are not connected.
|
||||||
|
// Call on the main thread: SIGINT/SIGTERM request a graceful session exit, and
|
||||||
|
// their previous handlers are restored before returning.
|
||||||
|
PreviewResult RunOpenXrPreview(const PreviewOptions& options = {});
|
||||||
|
std::string FormatPreviewResult(const PreviewResult& result);
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_OPENXR_PREVIEW_H_
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_OPENXR_PROBE_H_
|
||||||
|
#define MOCKTAIL_VR_OPENXR_PROBE_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
enum class ProbeStatus {
|
||||||
|
kReady,
|
||||||
|
kBuildDisabled,
|
||||||
|
kRuntimeUnavailable,
|
||||||
|
kHeadsetUnavailable,
|
||||||
|
kIncompatibleRuntime,
|
||||||
|
kError,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ViewConfiguration {
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
std::uint32_t samples = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ProbeReport {
|
||||||
|
ProbeStatus status = ProbeStatus::kError;
|
||||||
|
std::string message;
|
||||||
|
std::string sdk_version;
|
||||||
|
std::string runtime_name;
|
||||||
|
std::string runtime_version;
|
||||||
|
std::string system_name;
|
||||||
|
std::vector<std::string> extensions;
|
||||||
|
std::vector<ViewConfiguration> views;
|
||||||
|
bool orientation_tracking = false;
|
||||||
|
bool position_tracking = false;
|
||||||
|
std::string minimum_vulkan_version;
|
||||||
|
std::string maximum_vulkan_version;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native host discovery only. Does not load Roblox, change the active runtime,
|
||||||
|
// create a graphics device/session, or claim that VR rendering is implemented.
|
||||||
|
ProbeReport ProbeOpenXr();
|
||||||
|
std::string FormatProbeReport(const ProbeReport& report);
|
||||||
|
int ProbeExitCode(ProbeStatus status);
|
||||||
|
|
||||||
|
// False in builds configured without MOCKTAIL_ENABLE_VR; --vr fails closed
|
||||||
|
// early in that case instead of starting Roblox without VR support.
|
||||||
|
bool IsOpenXrSupportCompiled();
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_OPENXR_PROBE_H_
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_PERFORMANCE_STATS_H_
|
||||||
|
#define MOCKTAIL_VR_PERFORMANCE_STATS_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
struct PerformanceSnapshot {
|
||||||
|
std::uint64_t timestamp_ns = 0;
|
||||||
|
std::uint64_t frames = 0;
|
||||||
|
std::uint64_t last_frame_ns = 0;
|
||||||
|
std::uint64_t process = 0;
|
||||||
|
std::uint64_t width = 0, height = 0;
|
||||||
|
bool visible = false;
|
||||||
|
std::uint64_t render_width = 0, render_height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count successful application projections over a rolling second. Neither
|
||||||
|
// the scheduler's cap nor compositor repeats count as new game frames.
|
||||||
|
class PerformanceRate {
|
||||||
|
public:
|
||||||
|
void Reset() { samples_.clear(); }
|
||||||
|
double Sample(std::uint64_t now, const PerformanceSnapshot& snapshot) {
|
||||||
|
if (!snapshot.visible || snapshot.timestamp_ns > now ||
|
||||||
|
now - snapshot.timestamp_ns > kSecond ||
|
||||||
|
(!samples_.empty() && (now <= samples_.back().time ||
|
||||||
|
snapshot.frames < samples_.back().frames))) {
|
||||||
|
Reset();
|
||||||
|
return -1.;
|
||||||
|
}
|
||||||
|
samples_.push_back({now, snapshot.frames});
|
||||||
|
const auto cutoff = now > kSecond ? now - kSecond : 0;
|
||||||
|
while (samples_.size() > 2 && samples_[1].time <= cutoff)
|
||||||
|
samples_.pop_front();
|
||||||
|
if (!snapshot.last_frame_ns || snapshot.last_frame_ns > now ||
|
||||||
|
now - snapshot.last_frame_ns >= kSecond) return 0.;
|
||||||
|
const auto elapsed = now - samples_.front().time;
|
||||||
|
if (elapsed < kSecond / 2) return -1.;
|
||||||
|
return double(snapshot.frames - samples_.front().frames) * 1.e9 /
|
||||||
|
double(elapsed);
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
static constexpr std::uint64_t kSecond = 1'000'000'000;
|
||||||
|
struct SamplePoint { std::uint64_t time, frames; };
|
||||||
|
std::deque<SamplePoint> samples_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_PERFORMANCE_TELEMETRY_H_
|
||||||
|
#define MOCKTAIL_VR_PERFORMANCE_TELEMETRY_H_
|
||||||
|
|
||||||
|
#include "mocktail/vr/performance_stats.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <new>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// The launcher owns a private tmpfs file for the lifetime of one application.
|
||||||
|
// Only lock-free stores run in the frame path: no file writes, GPU waits or
|
||||||
|
// frame history allocations. A separate noninteractive overlay reads it.
|
||||||
|
struct alignas(64) SharedPerformanceStats {
|
||||||
|
static constexpr std::uint64_t kMagic = 0x4e43504600000002ULL;
|
||||||
|
std::atomic<std::uint64_t> magic{0}, sequence{0};
|
||||||
|
std::atomic<std::uint64_t> timestamp_ns{0}, frames{0}, last_frame_ns{0};
|
||||||
|
std::atomic<std::uint64_t> process{0}, width{0}, height{0}, visible{0};
|
||||||
|
std::atomic<std::uint64_t> render_width{0}, render_height{0};
|
||||||
|
};
|
||||||
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free);
|
||||||
|
|
||||||
|
class PerformanceMapping {
|
||||||
|
public:
|
||||||
|
PerformanceMapping() = default;
|
||||||
|
PerformanceMapping(const PerformanceMapping&) = delete;
|
||||||
|
PerformanceMapping& operator=(const PerformanceMapping&) = delete;
|
||||||
|
~PerformanceMapping() {
|
||||||
|
if (record_) munmap(record_, sizeof(SharedPerformanceStats));
|
||||||
|
}
|
||||||
|
bool Open(const char* path, bool writer) {
|
||||||
|
if (record_) return true;
|
||||||
|
if (!path || path[0] != '/') return false;
|
||||||
|
const int fd = open(path, (writer ? O_RDWR : O_RDONLY) | O_CLOEXEC | O_NOFOLLOW);
|
||||||
|
if (fd < 0) return false;
|
||||||
|
struct stat info{};
|
||||||
|
if (fstat(fd, &info) || !S_ISREG(info.st_mode) || info.st_uid != geteuid() ||
|
||||||
|
(info.st_mode & 0077) || info.st_size < static_cast<off_t>(sizeof(SharedPerformanceStats))) {
|
||||||
|
close(fd);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void* mapped = mmap(nullptr, sizeof(SharedPerformanceStats),
|
||||||
|
writer ? PROT_READ | PROT_WRITE : PROT_READ, MAP_SHARED, fd, 0);
|
||||||
|
close(fd);
|
||||||
|
if (mapped == MAP_FAILED) return false;
|
||||||
|
record_ = static_cast<SharedPerformanceStats*>(mapped);
|
||||||
|
if (writer) {
|
||||||
|
new (record_) SharedPerformanceStats;
|
||||||
|
record_->magic.store(SharedPerformanceStats::kMagic, std::memory_order_release);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void Publish(const PerformanceSnapshot& value) {
|
||||||
|
if (!record_) return;
|
||||||
|
record_->sequence.fetch_add(1, std::memory_order_acq_rel);
|
||||||
|
record_->timestamp_ns.store(value.timestamp_ns, std::memory_order_relaxed);
|
||||||
|
record_->frames.store(value.frames, std::memory_order_relaxed);
|
||||||
|
record_->last_frame_ns.store(value.last_frame_ns, std::memory_order_relaxed);
|
||||||
|
record_->process.store(value.process, std::memory_order_relaxed);
|
||||||
|
record_->width.store(value.width, std::memory_order_relaxed);
|
||||||
|
record_->height.store(value.height, std::memory_order_relaxed);
|
||||||
|
record_->visible.store(value.visible, std::memory_order_relaxed);
|
||||||
|
record_->render_width.store(value.render_width, std::memory_order_relaxed);
|
||||||
|
record_->render_height.store(value.render_height, std::memory_order_relaxed);
|
||||||
|
record_->sequence.fetch_add(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
bool Read(PerformanceSnapshot* value) const {
|
||||||
|
if (!record_ || record_->magic.load(std::memory_order_acquire) != SharedPerformanceStats::kMagic)
|
||||||
|
return false;
|
||||||
|
for (int attempt = 0; attempt < 8; ++attempt) {
|
||||||
|
const auto sequence = record_->sequence.load(std::memory_order_acquire);
|
||||||
|
if (sequence & 1) continue;
|
||||||
|
PerformanceSnapshot snapshot;
|
||||||
|
snapshot.timestamp_ns = record_->timestamp_ns.load(std::memory_order_relaxed);
|
||||||
|
snapshot.frames = record_->frames.load(std::memory_order_relaxed);
|
||||||
|
snapshot.last_frame_ns = record_->last_frame_ns.load(std::memory_order_relaxed);
|
||||||
|
snapshot.process = record_->process.load(std::memory_order_relaxed);
|
||||||
|
snapshot.width = record_->width.load(std::memory_order_relaxed);
|
||||||
|
snapshot.height = record_->height.load(std::memory_order_relaxed);
|
||||||
|
snapshot.visible = record_->visible.load(std::memory_order_relaxed) != 0;
|
||||||
|
snapshot.render_width = record_->render_width.load(std::memory_order_relaxed);
|
||||||
|
snapshot.render_height = record_->render_height.load(std::memory_order_relaxed);
|
||||||
|
std::atomic_thread_fence(std::memory_order_acquire);
|
||||||
|
if (sequence == record_->sequence.load(std::memory_order_relaxed)) {
|
||||||
|
*value = snapshot;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
SharedPerformanceStats* record_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PerformancePublisher {
|
||||||
|
public:
|
||||||
|
void Update(std::uint64_t now, std::uint64_t frames, bool visible,
|
||||||
|
std::uint32_t width, std::uint32_t height,
|
||||||
|
std::uint32_t render_width, std::uint32_t render_height) {
|
||||||
|
if (!attempted_) {
|
||||||
|
attempted_ = true;
|
||||||
|
process_ = static_cast<std::uint64_t>(getpid());
|
||||||
|
enabled_ = mapping_.Open(std::getenv("NIGHTCAP_PERFORMANCE_TELEMETRY"), true);
|
||||||
|
}
|
||||||
|
if (!enabled_) return;
|
||||||
|
if (frames != previous_frames_) last_frame_ns_ = now;
|
||||||
|
previous_frames_ = frames;
|
||||||
|
mapping_.Publish({now, frames, last_frame_ns_, process_,
|
||||||
|
width, height, visible, render_width, render_height});
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
PerformanceMapping mapping_;
|
||||||
|
bool attempted_ = false, enabled_ = false;
|
||||||
|
std::uint64_t previous_frames_ = 0, last_frame_ns_ = 0, process_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
inline bool ValidRasterJitter(bool enabled, const float (&jitter_pixels)[2],
|
||||||
|
std::uint32_t width, std::uint32_t height) {
|
||||||
|
return enabled && width && height &&
|
||||||
|
std::isfinite(jitter_pixels[0]) && std::isfinite(jitter_pixels[1]) &&
|
||||||
|
std::abs(jitter_pixels[0]) <= 1.f &&
|
||||||
|
std::abs(jitter_pixels[1]) <= 1.f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjusts the native projection's positive {up, down, left, right} tangent
|
||||||
|
// extents. Jitter is image displacement in render pixels: +X right, +Y down.
|
||||||
|
// With the existing right-handed camera looking down -Z, a ray projects to
|
||||||
|
// u=(x/-z+left)/(left+right), v=(up-y/-z)/(up+down). Translating both frustum
|
||||||
|
// bounds preserves its angular span and displaces every ray by the same pixels.
|
||||||
|
// Only the caller-owned projection changes; runtime FOV and tracking do not.
|
||||||
|
inline bool ApplyRasterProjectionJitter(float (&tangents)[4], bool enabled,
|
||||||
|
const float (&jitter_pixels)[2],
|
||||||
|
std::uint32_t render_width,
|
||||||
|
std::uint32_t render_height) {
|
||||||
|
if (!ValidRasterJitter(enabled, jitter_pixels, render_width, render_height))
|
||||||
|
return false;
|
||||||
|
for (float value : tangents)
|
||||||
|
if (!std::isfinite(value) || value <= 0.f)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const double dx = static_cast<double>(jitter_pixels[0]) *
|
||||||
|
(static_cast<double>(tangents[2]) + tangents[3]) /
|
||||||
|
render_width;
|
||||||
|
const double dy = static_cast<double>(jitter_pixels[1]) *
|
||||||
|
(static_cast<double>(tangents[0]) + tangents[1]) /
|
||||||
|
render_height;
|
||||||
|
const std::array<float, 4> shifted = {
|
||||||
|
static_cast<float>(tangents[0] + dy),
|
||||||
|
static_cast<float>(tangents[1] - dy),
|
||||||
|
static_cast<float>(tangents[2] + dx),
|
||||||
|
static_cast<float>(tangents[3] - dx)};
|
||||||
|
for (float value : shifted)
|
||||||
|
if (!std::isfinite(value) || value <= 0.f)
|
||||||
|
return false;
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
tangents[i] = shifted[i];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verified native Camera getter mirrors M02 for the right eye. Apply jitter
|
||||||
|
// after that operation to give both eyes the same displacement. Vulkan image Y
|
||||||
|
// increases downward; the verified native scene shader negates clip Y before
|
||||||
|
// its Vulkan viewport, while camera-space Y and this projection increase up.
|
||||||
|
inline bool ApplyRasterMatrixProjectionJitter(float (&matrix)[16], bool enabled,
|
||||||
|
const float (&jitter_pixels)[2],
|
||||||
|
std::uint32_t render_width,
|
||||||
|
std::uint32_t render_height) {
|
||||||
|
if (!ValidRasterJitter(enabled, jitter_pixels, render_width, render_height))
|
||||||
|
return false;
|
||||||
|
for (float value : matrix)
|
||||||
|
if (!std::isfinite(value))
|
||||||
|
return false;
|
||||||
|
if (matrix[0] <= 0.f || matrix[5] <= 0.f || matrix[14] != -1.f ||
|
||||||
|
matrix[15] != 0.f)
|
||||||
|
return false;
|
||||||
|
const float x = matrix[2] - 2.f * jitter_pixels[0] / render_width;
|
||||||
|
const float y = matrix[6] + 2.f * jitter_pixels[1] / render_height;
|
||||||
|
if (!std::isfinite(x) || !std::isfinite(y))
|
||||||
|
return false;
|
||||||
|
matrix[2] = x;
|
||||||
|
matrix[6] = y;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool ApplyRasterViewProjectionJitter(float (&matrix)[16], bool enabled,
|
||||||
|
const float (&jitter_pixels)[2],
|
||||||
|
std::uint32_t render_width,
|
||||||
|
std::uint32_t render_height) {
|
||||||
|
if (!ValidRasterJitter(enabled, jitter_pixels, render_width, render_height))
|
||||||
|
return false;
|
||||||
|
for (float value : matrix)
|
||||||
|
if (!std::isfinite(value))
|
||||||
|
return false;
|
||||||
|
double denominator_norm = 0.;
|
||||||
|
for (int i = 12; i < 16; ++i)
|
||||||
|
denominator_norm += static_cast<double>(matrix[i]) * matrix[i];
|
||||||
|
if (denominator_norm <= 0.)
|
||||||
|
return false;
|
||||||
|
const float dx = 2.f * jitter_pixels[0] / render_width;
|
||||||
|
const float dy = -2.f * jitter_pixels[1] / render_height;
|
||||||
|
float shifted[8];
|
||||||
|
for (int column = 0; column < 4; ++column) {
|
||||||
|
shifted[column] = matrix[column] + dx * matrix[12 + column];
|
||||||
|
shifted[4 + column] = matrix[4 + column] + dy * matrix[12 + column];
|
||||||
|
}
|
||||||
|
for (float value : shifted)
|
||||||
|
if (!std::isfinite(value))
|
||||||
|
return false;
|
||||||
|
std::copy_n(shifted, 8, matrix);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The observed native reverse-Z perspective builder writes M22=n/(f-n),
|
||||||
|
// M23=n*f/(f-n), M32=-1. Derive its actual clip planes without assuming a
|
||||||
|
// fixed near plane, which the client changes with its camera mode.
|
||||||
|
inline bool DecodeReverseProjectionDepth(const float (&matrix)[16],
|
||||||
|
float* camera_near,
|
||||||
|
float* camera_far,
|
||||||
|
bool* infinite) {
|
||||||
|
if (!camera_near || !camera_far || !infinite || matrix[14] != -1.f ||
|
||||||
|
matrix[15] != 0.f)
|
||||||
|
return false;
|
||||||
|
for (float value : matrix)
|
||||||
|
if (!std::isfinite(value))
|
||||||
|
return false;
|
||||||
|
const double a = matrix[10], b = matrix[11];
|
||||||
|
if (a < 0. || b <= 0.)
|
||||||
|
return false;
|
||||||
|
const float near = static_cast<float>(b / (a + 1.));
|
||||||
|
const bool is_infinite = a == 0.;
|
||||||
|
const float far = is_infinite ? std::numeric_limits<float>::infinity()
|
||||||
|
: static_cast<float>(b / a);
|
||||||
|
if (!std::isfinite(near) || near <= 0.f ||
|
||||||
|
(!is_infinite && (!std::isfinite(far) || far <= near)))
|
||||||
|
return false;
|
||||||
|
*camera_near = near;
|
||||||
|
*camera_far = far;
|
||||||
|
*infinite = is_infinite;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mocktail::vr::internal {
|
||||||
|
struct CpuCapacity { unsigned id; unsigned capacity; };
|
||||||
|
|
||||||
|
// Input contains only CPUs already allowed for this thread. Keep near-peer
|
||||||
|
// performance cores together, and retain the original mask if information is
|
||||||
|
// incomplete or a preference would leave fewer than two available cores.
|
||||||
|
inline std::vector<unsigned> PerformanceCpuSubset(
|
||||||
|
const std::vector<CpuCapacity>& allowed) {
|
||||||
|
std::vector<unsigned> original;
|
||||||
|
unsigned maximum = 0;
|
||||||
|
bool complete = true;
|
||||||
|
for (const auto& cpu : allowed) {
|
||||||
|
original.push_back(cpu.id);
|
||||||
|
maximum = std::max(maximum, cpu.capacity);
|
||||||
|
complete = complete && cpu.capacity > 0;
|
||||||
|
}
|
||||||
|
if (!complete || !maximum || allowed.size() < 2) return original;
|
||||||
|
std::vector<unsigned> selected;
|
||||||
|
for (const auto& cpu : allowed)
|
||||||
|
if (std::uint64_t(cpu.capacity) * 5 >= std::uint64_t(maximum) * 4)
|
||||||
|
selected.push_back(cpu.id);
|
||||||
|
return selected.size() >= 2 ? selected : original;
|
||||||
|
}
|
||||||
|
} // namespace mocktail::vr::internal
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
|
||||||
|
#define MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "compat/build_profile.h"
|
||||||
|
#include "mocktail/status.h"
|
||||||
|
#include "mocktail/vr/roblox_vr_guest_abi.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
namespace internal {
|
||||||
|
struct VrBridgeTestAccess;
|
||||||
|
struct VrBuildContract;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compat::VrDebugDeviceBridgeProfile* FindRobloxVrDeviceProfile(
|
||||||
|
std::string_view build_id);
|
||||||
|
|
||||||
|
// Human-readable list of the Roblox builds VR supports, e.g.
|
||||||
|
// "2998 (2.736.1408) and 3092 (2.738.1397)", for error messages.
|
||||||
|
std::string DescribeSupportedVrBuilds();
|
||||||
|
|
||||||
|
// Experimental native stereo bring-up for the exact Build-ID-scoped
|
||||||
|
// RBX::Graphics::DebugDeviceVR object of the guest client.
|
||||||
|
//
|
||||||
|
// The bridge interposes the state and eye getters of the exact guest build.
|
||||||
|
// Resource readiness is checked on the live object during each state call;
|
||||||
|
// initialization never dereferences cached guest object/device pointers. The
|
||||||
|
// guest owns resource destruction. Recreated objects and reused addresses
|
||||||
|
// therefore need no cache invalidation. Initialization happens only on a thread
|
||||||
|
// observed presenting.
|
||||||
|
class RobloxVrDeviceBridge final {
|
||||||
|
public:
|
||||||
|
using StateGetterFn = guest_abi::StateGetterFn;
|
||||||
|
using EyeGetterFn = void *(*)(void *device_object, int eye_index);
|
||||||
|
using EyeInitializerFn = void (*)(void *device_object, void *graphics_device);
|
||||||
|
|
||||||
|
RobloxVrDeviceBridge() = default;
|
||||||
|
~RobloxVrDeviceBridge();
|
||||||
|
|
||||||
|
RobloxVrDeviceBridge(const RobloxVrDeviceBridge &) = delete;
|
||||||
|
RobloxVrDeviceBridge &operator=(const RobloxVrDeviceBridge &) = delete;
|
||||||
|
|
||||||
|
// Arms the bridge for an exact-build profile that carries
|
||||||
|
// vr_debug_device_bridge metadata. Claims the process-wide bridge slot.
|
||||||
|
Status Install(const compat::BuildProfile &profile);
|
||||||
|
|
||||||
|
// Validates machine contracts at the loaded image base and interposes the
|
||||||
|
// two DebugDeviceVR vtable slots. Called once, after libroblox relocations
|
||||||
|
// and before any guest startup code runs.
|
||||||
|
Status Activate(std::uintptr_t image_base);
|
||||||
|
|
||||||
|
// Restores the interposed vtable slots and disarms. Safe to call twice.
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
bool installed() const { return installed_; }
|
||||||
|
bool active() const { return active_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
|
// Render-thread notification from the Vulkan adapter's host present path.
|
||||||
|
void NoteHostPresent();
|
||||||
|
|
||||||
|
// Guest-side hooks invoked by the interposed vtable stubs.
|
||||||
|
void OnHapticsCall(void* device_object, int hand, float amplitude);
|
||||||
|
// HapticService:SetMotor on Gamepad1 LeftHand (0) / RightHand (1); the
|
||||||
|
// level holds until the game sets it again.
|
||||||
|
void OnSetMotorCall(int hand, float amplitude);
|
||||||
|
void OnPointerFrameCall(void* result, void* service);
|
||||||
|
void OnStateGetterCall(void *result_buffer, void *device_object);
|
||||||
|
void OnEyeGetterCall(void *device_object, int eye_index, void *framebuffer);
|
||||||
|
|
||||||
|
StateGetterFn original_state_getter() const {
|
||||||
|
return original_state_getter_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
EyeGetterFn original_eye_getter() const {
|
||||||
|
return original_eye_getter_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cumulative initialization diagnostics (not a count of live devices).
|
||||||
|
std::uintptr_t image_base() const {
|
||||||
|
return image_base_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
int captured_device_count() const;
|
||||||
|
int initialized_device_count() const;
|
||||||
|
std::uint64_t present_count() const {
|
||||||
|
return present_count_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t eye_getter_call_count(int eye_index) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend struct internal::VrBridgeTestAccess;
|
||||||
|
static constexpr std::uint64_t kEyeGetterSummaryInterval = 600;
|
||||||
|
bool ValidateObjectHeader(void *self) const;
|
||||||
|
void *ValidatedGraphicsDevice(void *self) const;
|
||||||
|
void TryInitializeEyes(void *self);
|
||||||
|
bool RestoreVtableLocked();
|
||||||
|
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
compat::VrDebugDeviceBridgeProfile profile_{};
|
||||||
|
const internal::VrBuildContract* contract_ = nullptr;
|
||||||
|
bool installed_ = false;
|
||||||
|
std::atomic<bool> active_{false};
|
||||||
|
std::atomic<std::uintptr_t> image_base_{0};
|
||||||
|
std::atomic<std::uintptr_t> image_size_{0};
|
||||||
|
std::uintptr_t *vtable_ = nullptr;
|
||||||
|
std::uintptr_t original_haptic_slot_ = 0;
|
||||||
|
std::uintptr_t original_state_slot_ = 0;
|
||||||
|
std::uintptr_t original_eye_slot_ = 0;
|
||||||
|
std::atomic<StateGetterFn> original_state_getter_{nullptr};
|
||||||
|
std::atomic<EyeGetterFn> original_eye_getter_{nullptr};
|
||||||
|
std::atomic<std::uint64_t> present_thread_{0};
|
||||||
|
std::atomic<std::uint64_t> present_count_{0};
|
||||||
|
std::atomic<bool> flag_warning_logged_{false};
|
||||||
|
std::atomic<int> captured_count_{0};
|
||||||
|
std::atomic<int> initialized_count_{0};
|
||||||
|
mutable std::atomic<std::uint64_t> eye_call_totals_[2] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process-wide single active bridge, mirroring the audio output-device
|
||||||
|
// bridge ownership model. Install claims the slot; the destructor releases
|
||||||
|
// it. legacy_runtime forwards the loaded libroblox image base through
|
||||||
|
// NotifyRobloxVrImageLoaded; it is a successful no-op while no bridge is
|
||||||
|
// armed, and fail-closed once one is.
|
||||||
|
Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base);
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_ROBLOX_VR_GUEST_ABI_H_
|
||||||
|
#define MOCKTAIL_VR_ROBLOX_VR_GUEST_ABI_H_
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
namespace mocktail::vr::guest_abi {
|
||||||
|
// AAPCS64 uses x0 for self and x8 for an indirect aggregate result. Declaring
|
||||||
|
// the actual aggregate return lets the compiler preserve the guest ABI.
|
||||||
|
struct StateResult { std::uint8_t bytes[0x138]; };
|
||||||
|
struct FrameResult { float elements[12]; };
|
||||||
|
static_assert(sizeof(StateResult) == 0x138);
|
||||||
|
static_assert(sizeof(FrameResult) == 0x30);
|
||||||
|
#if defined(__aarch64__)
|
||||||
|
using StateGetterFn = StateResult (*)(void* self);
|
||||||
|
using PointerFrameFn = FrameResult (*)(void* self);
|
||||||
|
inline void CallState(StateGetterFn fn, void* result, void* self) {
|
||||||
|
const auto value = fn(self);
|
||||||
|
std::memcpy(result, &value, sizeof(value));
|
||||||
|
}
|
||||||
|
inline void CallPointer(PointerFrameFn fn, void* result, void* self) {
|
||||||
|
const auto value = fn(self);
|
||||||
|
std::memcpy(result, &value, sizeof(value));
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
using StateGetterFn = void* (*)(void* result, void* self);
|
||||||
|
using PointerFrameFn = void* (*)(void* result, void* self);
|
||||||
|
inline void CallState(StateGetterFn fn, void* result, void* self) {
|
||||||
|
fn(result, self);
|
||||||
|
}
|
||||||
|
inline void CallPointer(PointerFrameFn fn, void* result, void* self) {
|
||||||
|
fn(result, self);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} // namespace mocktail::vr::guest_abi
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "mocktail/vr/vr_pose_math.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
struct TemporalEyePose {
|
||||||
|
float orientation[4]={0,0,0,1};
|
||||||
|
float fov[4]={-1,1,1,-1};
|
||||||
|
float position[3]={};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Depthless rotational reprojection. Translation has no reliable depth-based
|
||||||
|
// correspondence, so reject history during appreciable eye translation.
|
||||||
|
inline bool TemporalReprojection(const TemporalEyePose& current,
|
||||||
|
const TemporalEyePose& previous,
|
||||||
|
float rows[3][4]) {
|
||||||
|
pose::Quat c,p;
|
||||||
|
if(!pose::QuatNormalize({current.orientation[0],current.orientation[1],current.orientation[2],current.orientation[3]},&c) ||
|
||||||
|
!pose::QuatNormalize({previous.orientation[0],previous.orientation[1],previous.orientation[2],previous.orientation[3]},&p))return false;
|
||||||
|
float distance=0;
|
||||||
|
for(int i=0;i<3;++i){const float d=current.position[i]-previous.position[i];if(!std::isfinite(d))return false;distance+=d*d;}
|
||||||
|
if(distance>.0025f*.0025f)return false;
|
||||||
|
const auto rotation=pose::QuatMultiply(pose::QuatConjugate(p),c);
|
||||||
|
if(std::abs(rotation.w)<.99905f)return false; // Reject large jumps (>5 degrees/frame).
|
||||||
|
float f[4],old[4];
|
||||||
|
for(int i=0;i<4;++i){f[i]=std::tan(current.fov[i]);old[i]=std::tan(previous.fov[i]);if(!std::isfinite(f[i]+old[i]))return false;}
|
||||||
|
if(f[1]<=f[0] || f[2]<=f[3] || old[1]<=old[0] || old[2]<=old[3])return false;
|
||||||
|
const pose::Vec3 basis[3]={{f[1]-f[0],0,0},{0,f[3]-f[2],0},{f[0],f[2],-1}};
|
||||||
|
for(int column=0;column<3;++column){
|
||||||
|
const auto ray=pose::QuatRotate(rotation,basis[column]);
|
||||||
|
rows[0][column]=(ray.x+old[0]*ray.z)/(old[1]-old[0]);
|
||||||
|
rows[1][column]=(-old[2]*ray.z-ray.y)/(old[2]-old[3]);
|
||||||
|
rows[2][column]=-ray.z;
|
||||||
|
}
|
||||||
|
for(int i=0;i<3;++i)rows[i][3]=0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_VIRTUAL_KEYBOARD_H_
|
||||||
|
#define MOCKTAIL_VR_VIRTUAL_KEYBOARD_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "mocktail/vr/vr_pose_math.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// Host side of the in-headset keyboard. The window module owns Roblox's
|
||||||
|
// TextBox focus and the SDL event path; main wires these callbacks so the VR
|
||||||
|
// library never links the window library. Every callback may be called from
|
||||||
|
// the render thread and must be thread-safe.
|
||||||
|
struct VirtualKeyboardHost {
|
||||||
|
void* context = nullptr;
|
||||||
|
// True while Roblox has asked for an on-screen keyboard (showKeyboard).
|
||||||
|
bool (*input_requested)(void* context) = nullptr;
|
||||||
|
// Commits UTF-8 text exactly like an SDL_EVENT_TEXT_INPUT.
|
||||||
|
void (*commit_text)(void* context, const char* utf8) = nullptr;
|
||||||
|
// Presses and releases one SDL scancode exactly like a physical key.
|
||||||
|
void (*press_key)(void* context, std::uint32_t scancode) = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Installs the process-wide host. Passing nullptr clears it.
|
||||||
|
void SetVirtualKeyboardHost(const VirtualKeyboardHost* host);
|
||||||
|
VirtualKeyboardHost CurrentVirtualKeyboardHost();
|
||||||
|
|
||||||
|
// The focused TextBox's current contents, shown in the panel's text field.
|
||||||
|
// The desktop draws TextBox text as a window overlay that never reaches the
|
||||||
|
// eye images, so the headset would otherwise show an empty box. Safe from any
|
||||||
|
// thread; the text is sensitive and is never logged.
|
||||||
|
struct VirtualKeyboardText {
|
||||||
|
std::string utf8;
|
||||||
|
int cursor_utf16 = 0;
|
||||||
|
bool password = false;
|
||||||
|
std::uint64_t revision = 0;
|
||||||
|
};
|
||||||
|
void SetVirtualKeyboardText(const char* utf8, std::size_t size,
|
||||||
|
int cursor_utf16, bool password);
|
||||||
|
void ClearVirtualKeyboardText();
|
||||||
|
VirtualKeyboardText CurrentVirtualKeyboardText();
|
||||||
|
|
||||||
|
enum class VirtualKeyAction {
|
||||||
|
kText, // commits text, or shift_text under Shift/Caps Lock
|
||||||
|
kBackspace,
|
||||||
|
kEnter,
|
||||||
|
kTab,
|
||||||
|
kShift, // one-shot
|
||||||
|
kCapsLock,
|
||||||
|
kSpace,
|
||||||
|
kClose, // Escape: leaves the TextBox and hides the panel
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VirtualKey {
|
||||||
|
std::string label;
|
||||||
|
std::string text; // committed text for kText
|
||||||
|
std::string shift_text; // committed under Shift (Caps Lock for letters)
|
||||||
|
VirtualKeyAction action = VirtualKeyAction::kText;
|
||||||
|
// Panel UV rectangle, origin top-left, both axes 0..1.
|
||||||
|
float u0 = 0.f, v0 = 0.f, u1 = 0.f, v1 = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One controller's pointing state for a frame. Positions are metres in the
|
||||||
|
// OpenXR LOCAL space; the ray points along the aim pose's -Z axis.
|
||||||
|
struct VirtualKeyboardPointer {
|
||||||
|
bool valid = false;
|
||||||
|
pose::Pose aim{};
|
||||||
|
float trigger = 0.f; // 0..1
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the keyboard asks the host to do after an Update.
|
||||||
|
struct VirtualKeyboardOutput {
|
||||||
|
std::vector<std::string> text; // committed in order
|
||||||
|
std::vector<std::uint32_t> scancodes; // pressed in order, after text
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ray/quad intersection. The quad is centred on `quad` with its face along
|
||||||
|
// +Z, `width` x `height` metres. Returns false when the ray misses or points
|
||||||
|
// away. u/v are panel UVs (origin top-left).
|
||||||
|
bool IntersectVirtualKeyboardPanel(const pose::Pose& quad, float width,
|
||||||
|
float height, const pose::Pose& aim,
|
||||||
|
float* u, float* v, float* distance);
|
||||||
|
|
||||||
|
// Pure keyboard state machine: layout, paging, shift, hover, trigger edges and
|
||||||
|
// backspace auto-repeat. No OpenXR, Vulkan or SDL types, so it is unit-tested
|
||||||
|
// directly.
|
||||||
|
class VirtualKeyboard {
|
||||||
|
public:
|
||||||
|
// A text field over 15 x 5 normal-key units, 4 cm per unit.
|
||||||
|
static constexpr float kPanelWidthMetres = 0.60f;
|
||||||
|
static constexpr float kPanelHeightMetres = 0.248f;
|
||||||
|
// Texture aspect matches the panel so keys are not stretched.
|
||||||
|
static constexpr int kTextureWidth = 1500;
|
||||||
|
static constexpr int kTextureHeight = 620;
|
||||||
|
// Top share of the panel holding the text field; keys fill the rest.
|
||||||
|
static constexpr float kFieldFraction = 120.f / 620.f;
|
||||||
|
|
||||||
|
VirtualKeyboard();
|
||||||
|
|
||||||
|
// Opens the panel in front of `head` (LOCAL space), below eye level and
|
||||||
|
// tilted up toward the viewer. Resets Shift and hover; keeps Caps Lock.
|
||||||
|
void Show(const pose::Pose& head);
|
||||||
|
void Hide();
|
||||||
|
bool visible() const { return visible_; }
|
||||||
|
const pose::Pose& panel_pose() const { return panel_; }
|
||||||
|
|
||||||
|
// Text shown in the field. Bumps the revision only when it changed.
|
||||||
|
void SetText(const VirtualKeyboardText& text);
|
||||||
|
const std::string& text() const { return text_.utf8; }
|
||||||
|
int text_cursor_utf16() const { return text_.cursor_utf16; }
|
||||||
|
bool text_password() const { return text_.password; }
|
||||||
|
|
||||||
|
// Processes one frame of both pointers. now_ns is monotonic.
|
||||||
|
VirtualKeyboardOutput Update(std::uint64_t now_ns,
|
||||||
|
const VirtualKeyboardPointer (&pointers)[2]);
|
||||||
|
|
||||||
|
// True when this hand's trigger belongs to the keyboard this frame (aimed
|
||||||
|
// at the panel, or a press that began on it is still held, including after
|
||||||
|
// the panel hid mid-press). The backend withholds that trigger from Roblox
|
||||||
|
// so a key press is not also a click.
|
||||||
|
bool CapturesTrigger(int hand) const;
|
||||||
|
|
||||||
|
// Current layout, with Shift/Caps Lock applied to the labels.
|
||||||
|
const std::vector<VirtualKey>& keys() const;
|
||||||
|
// -1 when the hand is not over a key.
|
||||||
|
int hovered_key(int hand) const { return hands_[hand & 1].hovered; }
|
||||||
|
int pressed_key(int hand) const { return hands_[hand & 1].pressed; }
|
||||||
|
// Pointer position on the panel, when the hand is over it.
|
||||||
|
bool cursor(int hand, float* u, float* v) const;
|
||||||
|
bool shifted() const { return shift_; }
|
||||||
|
bool caps_lock() const { return caps_lock_; }
|
||||||
|
|
||||||
|
// Advances whenever anything visible changes; the backend re-rasterizes
|
||||||
|
// and re-uploads only when this moves.
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Hand {
|
||||||
|
int hovered = -1;
|
||||||
|
int pressed = -1; // key under an active press, -1 otherwise
|
||||||
|
bool trigger_down = false;
|
||||||
|
bool captured = false;
|
||||||
|
bool over_panel = false;
|
||||||
|
float u = 0.f, v = 0.f;
|
||||||
|
std::uint64_t repeat_at_ns = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void RebuildLayout();
|
||||||
|
int KeyAt(float u, float v) const;
|
||||||
|
std::string Output(const VirtualKey& key) const;
|
||||||
|
void Activate(const VirtualKey& key, VirtualKeyboardOutput* output);
|
||||||
|
void Touch() { ++revision_; }
|
||||||
|
|
||||||
|
bool visible_ = false;
|
||||||
|
bool shift_ = false;
|
||||||
|
bool caps_lock_ = false;
|
||||||
|
pose::Pose panel_{};
|
||||||
|
VirtualKeyboardText text_;
|
||||||
|
Hand hands_[2];
|
||||||
|
// A press the keyboard owned when it hid; held until that trigger releases.
|
||||||
|
bool release_pending_[2] = {false, false};
|
||||||
|
std::uint64_t cursor_drawn_ns_ = 0;
|
||||||
|
std::vector<VirtualKey> keys_;
|
||||||
|
std::uint64_t revision_ = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draws the keyboard into a tightly packed RGBA8 buffer
|
||||||
|
// (VirtualKeyboard::kTextureWidth x kTextureHeight, sRGB-encoded, opaque).
|
||||||
|
// Glyphs come from SDL3_ttf with fontconfig's sans fallback chain and are
|
||||||
|
// cached per label, so redraws after the first are plain fills and blits.
|
||||||
|
class VirtualKeyboardRasterizer {
|
||||||
|
public:
|
||||||
|
VirtualKeyboardRasterizer();
|
||||||
|
~VirtualKeyboardRasterizer();
|
||||||
|
VirtualKeyboardRasterizer(const VirtualKeyboardRasterizer&) = delete;
|
||||||
|
VirtualKeyboardRasterizer& operator=(const VirtualKeyboardRasterizer&) =
|
||||||
|
delete;
|
||||||
|
|
||||||
|
// Returns false when no font could be opened; keys still draw, unlabelled.
|
||||||
|
bool Draw(const VirtualKeyboard& keyboard, std::vector<std::uint8_t>* rgba);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
Impl* impl_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_VIRTUAL_KEYBOARD_H_
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_MIRROR_H_
|
||||||
|
#define MOCKTAIL_VR_MIRROR_H_
|
||||||
|
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "mocktail/vr/vr_mirror_shm.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// The subset of device/instance procs the mirror needs, supplied by the XR
|
||||||
|
// backend from its already-resolved guest-device table. Keeping this explicit
|
||||||
|
// avoids a second proc table and keeps the mirror independently testable.
|
||||||
|
struct MirrorVkProcs {
|
||||||
|
PFN_vkCreateBuffer CreateBuffer = nullptr;
|
||||||
|
PFN_vkDestroyBuffer DestroyBuffer = nullptr;
|
||||||
|
PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements = nullptr;
|
||||||
|
PFN_vkAllocateMemory AllocateMemory = nullptr;
|
||||||
|
PFN_vkFreeMemory FreeMemory = nullptr;
|
||||||
|
PFN_vkBindBufferMemory BindBufferMemory = nullptr;
|
||||||
|
PFN_vkMapMemory MapMemory = nullptr;
|
||||||
|
PFN_vkUnmapMemory UnmapMemory = nullptr;
|
||||||
|
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer = nullptr;
|
||||||
|
PFN_vkCmdPipelineBarrier CmdPipelineBarrier = nullptr;
|
||||||
|
PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties =
|
||||||
|
nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Producer side of the desktop two-eye mirror. Maps a POSIX shared-memory
|
||||||
|
// segment (created with the name from MOCKTAIL_VR_MIRROR_SHM) and publishes
|
||||||
|
// the latest complete eye pair under a seqlock. It owns two persistent,
|
||||||
|
// bounded host-visible staging buffers reused across frames; the GPU->staging
|
||||||
|
// copies are recorded into the caller's single batched command buffer so the
|
||||||
|
// mirror adds no extra queue submission or fence wait.
|
||||||
|
//
|
||||||
|
// The mirror never touches the filesystem on the frame path and keeps only the
|
||||||
|
// newest pair (one latest slot), so a slow consumer drops rather than queues
|
||||||
|
// old frames.
|
||||||
|
class VrMirror {
|
||||||
|
public:
|
||||||
|
VrMirror() = default;
|
||||||
|
~VrMirror();
|
||||||
|
VrMirror(const VrMirror&) = delete;
|
||||||
|
VrMirror& operator=(const VrMirror&) = delete;
|
||||||
|
|
||||||
|
bool enabled() const { return mapping_ != nullptr; }
|
||||||
|
|
||||||
|
// Opens the shared segment. Returns false (and stays disabled) when the name
|
||||||
|
// is empty or mapping fails; the XR path then runs without a mirror.
|
||||||
|
bool Open(const std::string& shm_name, std::uint32_t width,
|
||||||
|
std::uint32_t height, std::string* error);
|
||||||
|
void Close();
|
||||||
|
|
||||||
|
// Ensures the two staging buffers match width*height*4 bytes on this device.
|
||||||
|
// Allocates once; a size/device change recreates them. Existing contents are
|
||||||
|
// discarded on resize.
|
||||||
|
bool EnsureStaging(const MirrorVkProcs& vk, VkDevice device,
|
||||||
|
VkPhysicalDevice physical, std::uint32_t width,
|
||||||
|
std::uint32_t height, std::string* error);
|
||||||
|
void DestroyStaging(const MirrorVkProcs& vk);
|
||||||
|
|
||||||
|
// Records a source-eye -> staging-buffer copy for one eye into cmd, adding
|
||||||
|
// the layout barriers around it. The source is returned to final_layout so
|
||||||
|
// the next guest render pass sees it unchanged.
|
||||||
|
void RecordEyeCopy(const MirrorVkProcs& vk, VkCommandBuffer cmd, int eye,
|
||||||
|
VkImage source, VkImageLayout final_layout,
|
||||||
|
std::uint32_t width, std::uint32_t height);
|
||||||
|
|
||||||
|
// After the batched fence wait completed, copies both staging buffers into
|
||||||
|
// the shared segment and bumps the seqlock with the frame id and a monotonic
|
||||||
|
// timestamp. No-op when staging does not cover width*height.
|
||||||
|
void Publish(std::uint64_t frame, VkFormat format, std::uint32_t width,
|
||||||
|
std::uint32_t height, std::uint64_t now_ns);
|
||||||
|
|
||||||
|
std::uint64_t published_pairs() const { return published_pairs_; }
|
||||||
|
std::uint32_t staging_width() const { return staging_width_; }
|
||||||
|
std::uint32_t staging_height() const { return staging_height_; }
|
||||||
|
const void* mapping() const { return mapping_; }
|
||||||
|
|
||||||
|
// Test seam: publish a pair from caller-supplied staging bytes without any
|
||||||
|
// Vulkan resources, exercising the same seqlock/segment write as the GPU
|
||||||
|
// path. Both buffers must hold width*height*4 bytes.
|
||||||
|
void PublishFromBytesForTest(std::uint64_t frame, VkFormat format,
|
||||||
|
std::uint32_t width, std::uint32_t height,
|
||||||
|
std::uint64_t now_ns, const void* eye0,
|
||||||
|
const void* eye1);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* mapping_ = nullptr;
|
||||||
|
std::uint64_t mapping_bytes_ = 0;
|
||||||
|
std::string shm_name_;
|
||||||
|
VkBuffer staging_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE};
|
||||||
|
VkDeviceMemory staging_memory_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE};
|
||||||
|
void* staging_map_[2] = {nullptr, nullptr};
|
||||||
|
std::uint32_t staging_width_ = 0;
|
||||||
|
std::uint32_t staging_height_ = 0;
|
||||||
|
VkDevice staging_device_ = VK_NULL_HANDLE;
|
||||||
|
std::uint64_t published_pairs_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_MIRROR_H_
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_MIRROR_SHM_H_
|
||||||
|
#define MOCKTAIL_VR_MIRROR_SHM_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// Shared-memory transport for the desktop two-eye mirror. The producer (XR
|
||||||
|
// backend, inside the client process) publishes the latest complete eye pair
|
||||||
|
// under a seqlock; the consumer (desktop viewer) reads the newest pair without
|
||||||
|
// any per-frame filesystem transport, image encode/decode, or allocation.
|
||||||
|
//
|
||||||
|
// Layout is fixed and little-endian.
|
||||||
|
// sizeof(VrMirrorHeader) == kVrMirrorHeaderBytes.
|
||||||
|
struct VrMirrorHeader {
|
||||||
|
std::uint32_t magic; // kVrMirrorMagic
|
||||||
|
std::uint32_t header_bytes; // kVrMirrorHeaderBytes (pixel area offset)
|
||||||
|
std::uint64_t seq; // seqlock: odd while writing, even when stable
|
||||||
|
std::uint64_t frame; // XR frame id the pair belongs to
|
||||||
|
std::uint32_t width; // eye width in pixels
|
||||||
|
std::uint32_t height; // eye height in pixels
|
||||||
|
std::uint32_t format; // VkFormat of the source eye images
|
||||||
|
std::uint32_t flags; // bit0: a complete, valid pair is present
|
||||||
|
std::uint64_t producer_ns; // CLOCK_MONOTONIC publish timestamp
|
||||||
|
std::uint64_t published_pairs;
|
||||||
|
std::uint64_t reserved;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr std::uint32_t kVrMirrorMagic = 0x3152564D; // 'MVR1'
|
||||||
|
inline constexpr std::uint32_t kVrMirrorHeaderBytes = 64;
|
||||||
|
inline constexpr std::uint32_t kVrMirrorFlagValid = 1u << 0;
|
||||||
|
|
||||||
|
// Total mapped bytes for a given eye extent (header + two RGBA/BGRA images).
|
||||||
|
inline std::uint64_t VrMirrorBytes(std::uint32_t width, std::uint32_t height) {
|
||||||
|
return static_cast<std::uint64_t>(kVrMirrorHeaderBytes) +
|
||||||
|
2ull * static_cast<std::uint64_t>(width) *
|
||||||
|
static_cast<std::uint64_t>(height) * 4ull;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_MIRROR_SHM_H_
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_PERF_H_
|
||||||
|
#define MOCKTAIL_VR_PERF_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mocktail::vr::perf {
|
||||||
|
|
||||||
|
// Opt-in, low-overhead stage timing and counter instrumentation. Collection is
|
||||||
|
// compiled out at runtime unless MOCKTAIL_VR_PERF is set, so the steady-state
|
||||||
|
// path only pays a single cached boolean test per record.
|
||||||
|
bool Enabled();
|
||||||
|
|
||||||
|
enum class Stage {
|
||||||
|
kFrameCycle,
|
||||||
|
kXrWaitFrame,
|
||||||
|
kXrBeginFrame,
|
||||||
|
kXrLocateViews,
|
||||||
|
kSwapchainAcquire,
|
||||||
|
kSwapchainWait,
|
||||||
|
kCopyRecord,
|
||||||
|
kCopySubmit,
|
||||||
|
kCopyFenceWait,
|
||||||
|
kMirrorPublish,
|
||||||
|
kEvidence,
|
||||||
|
kControllerSync,
|
||||||
|
kPresentDependencies,
|
||||||
|
kDesktopMirror,
|
||||||
|
kPresentPrepare,
|
||||||
|
kPresentInterval,
|
||||||
|
kStageCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class Counter {
|
||||||
|
kPresents,
|
||||||
|
kFramesBegun,
|
||||||
|
kFramesSubmitted,
|
||||||
|
kFramesSkipped,
|
||||||
|
kSkipShouldRender,
|
||||||
|
kSkipPoseInvalid,
|
||||||
|
kSkipEyesUnbound,
|
||||||
|
kSkipEyeFrameMismatch,
|
||||||
|
kSubmits,
|
||||||
|
kFenceWaits,
|
||||||
|
kMirrorPairs,
|
||||||
|
kMirrorDrops,
|
||||||
|
kSkipVisibilityLost,
|
||||||
|
kReferenceSpaceChanges,
|
||||||
|
kVisibilityLost,
|
||||||
|
kVisibilityGained,
|
||||||
|
kFramesInvalidated,
|
||||||
|
kControllerSyncs,
|
||||||
|
kCounterCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* StageName(Stage stage);
|
||||||
|
const char* CounterName(Counter counter);
|
||||||
|
|
||||||
|
// Bounded reservoir per stage: the most recent N samples are kept and ranked
|
||||||
|
// only when a report is emitted (once per interval), never on the frame path.
|
||||||
|
class Collector {
|
||||||
|
public:
|
||||||
|
static constexpr std::size_t kReservoir = 4096;
|
||||||
|
static constexpr std::uint64_t kIntervalNs = 1'000'000'000ULL;
|
||||||
|
|
||||||
|
void Record(Stage stage, std::uint64_t nanos);
|
||||||
|
void Count(Counter counter, std::uint64_t delta = 1);
|
||||||
|
|
||||||
|
// Emits one distribution line per active stage plus a counter line when at
|
||||||
|
// least kIntervalNs elapsed since the previous emission. Cheap no-op
|
||||||
|
// otherwise. now_ns is a CLOCK_MONOTONIC timestamp supplied by the caller so
|
||||||
|
// the collector stays clock-agnostic and testable.
|
||||||
|
void EmitIfDue(std::uint64_t now_ns, const char* context);
|
||||||
|
|
||||||
|
// Test/inspection accessors.
|
||||||
|
std::uint64_t counter(Counter counter_id) const;
|
||||||
|
std::vector<std::uint64_t> Percentiles(Stage stage) const; // p50,p95,p99,max
|
||||||
|
std::size_t Samples(Stage stage) const;
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct StageStat {
|
||||||
|
std::vector<std::uint64_t> samples; // ring, bounded by kReservoir
|
||||||
|
std::size_t head = 0;
|
||||||
|
std::size_t count = 0;
|
||||||
|
std::uint64_t total_ns = 0;
|
||||||
|
};
|
||||||
|
StageStat stages_[static_cast<std::size_t>(Stage::kStageCount)]{};
|
||||||
|
std::uint64_t counters_[static_cast<std::size_t>(Counter::kCounterCount)]{};
|
||||||
|
std::uint64_t last_emit_ns_ = 0;
|
||||||
|
std::uint64_t window_start_ns_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process-wide collector used by the XR backend frame cycle.
|
||||||
|
Collector& ProcessCollector();
|
||||||
|
|
||||||
|
// Monotonic nanoseconds (CLOCK_MONOTONIC), 0 on failure.
|
||||||
|
std::uint64_t NowNs();
|
||||||
|
|
||||||
|
} // namespace mocktail::vr::perf
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_PERF_H_
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_POSE_MATH_H_
|
||||||
|
#define MOCKTAIL_VR_POSE_MATH_H_
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mocktail::vr::pose {
|
||||||
|
|
||||||
|
// Coordinate spaces and units used across the whole camera path, documented so
|
||||||
|
// every stage is auditable:
|
||||||
|
//
|
||||||
|
// OpenXR head/views metres, right-handed, +Y up, -Z forward, LOCAL space
|
||||||
|
// -> published pose metres, quaternion (x, y, z, w), unchanged units
|
||||||
|
// -> guest state metres; the exact-build native helper converts metres
|
||||||
|
// to studs itself (factor 10/3). This layer must never
|
||||||
|
// pre-apply that scale.
|
||||||
|
// -> camera engine composes
|
||||||
|
// out.rotation = base.rotation * head.rotation
|
||||||
|
// out.position = base.position
|
||||||
|
// + base.rotation * head.position * HeadScale
|
||||||
|
// where HeadScale is an engine value distinct from the
|
||||||
|
// metres-to-studs conversion factor.
|
||||||
|
// -> render one pass per eye using the per-eye offset/FOV
|
||||||
|
// -> XR submission metres again, the very pose used to render the pair.
|
||||||
|
|
||||||
|
struct Vec3 {
|
||||||
|
float x = 0.f, y = 0.f, z = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Quat {
|
||||||
|
float x = 0.f, y = 0.f, z = 0.f, w = 1.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Pose {
|
||||||
|
Vec3 position{};
|
||||||
|
Quat orientation{};
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr float kPi = 3.14159265358979323846f;
|
||||||
|
|
||||||
|
inline Quat QuatMultiply(const Quat& a, const Quat& b) {
|
||||||
|
return Quat{
|
||||||
|
a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
||||||
|
a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
||||||
|
a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
||||||
|
a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Quat QuatConjugate(const Quat& q) { return Quat{-q.x, -q.y, -q.z, q.w}; }
|
||||||
|
|
||||||
|
inline float QuatNorm(const Quat& q) {
|
||||||
|
return std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns false for a non-finite or near-zero quaternion; the output is left
|
||||||
|
// untouched in that case so callers never publish a fabricated rotation.
|
||||||
|
inline bool QuatNormalize(const Quat& in, Quat* out) {
|
||||||
|
if (out == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!std::isfinite(in.x) || !std::isfinite(in.y) || !std::isfinite(in.z) ||
|
||||||
|
!std::isfinite(in.w)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float norm = QuatNorm(in);
|
||||||
|
if (!(norm > 1e-6f) || !std::isfinite(norm)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float inverse = 1.f / norm;
|
||||||
|
*out = Quat{in.x * inverse, in.y * inverse, in.z * inverse, in.w * inverse};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// q and -q denote the same rotation. Used to prove the path is sign-invariant.
|
||||||
|
inline bool QuatSameRotation(const Quat& a, const Quat& b, float tolerance) {
|
||||||
|
const float dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
|
||||||
|
return std::abs(std::abs(dot) - 1.f) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Vec3 QuatRotate(const Quat& q, const Vec3& v) {
|
||||||
|
// t = 2 * cross(q.xyz, v); v' = v + q.w * t + cross(q.xyz, t)
|
||||||
|
const Vec3 u{q.x, q.y, q.z};
|
||||||
|
const Vec3 cross1{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z,
|
||||||
|
u.x * v.y - u.y * v.x};
|
||||||
|
const Vec3 t{2.f * cross1.x, 2.f * cross1.y, 2.f * cross1.z};
|
||||||
|
const Vec3 cross2{u.y * t.z - u.z * t.y, u.z * t.x - u.x * t.z,
|
||||||
|
u.x * t.y - u.y * t.x};
|
||||||
|
return Vec3{v.x + q.w * t.x + cross2.x, v.y + q.w * t.y + cross2.y,
|
||||||
|
v.z + q.w * t.z + cross2.z};
|
||||||
|
}
|
||||||
|
|
||||||
|
// result = a * b in SE(3): apply b first, then a.
|
||||||
|
inline Pose PoseMultiply(const Pose& a, const Pose& b) {
|
||||||
|
Pose result;
|
||||||
|
result.orientation = QuatMultiply(a.orientation, b.orientation);
|
||||||
|
const Vec3 rotated = QuatRotate(a.orientation, b.position);
|
||||||
|
result.position = Vec3{a.position.x + rotated.x, a.position.y + rotated.y,
|
||||||
|
a.position.z + rotated.z};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Pose PoseInverse(const Pose& p) {
|
||||||
|
Pose result;
|
||||||
|
result.orientation = QuatConjugate(p.orientation);
|
||||||
|
const Vec3 neg{-p.position.x, -p.position.y, -p.position.z};
|
||||||
|
result.position = QuatRotate(result.orientation, neg);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation for a head turned by yaw about +Y then pitched about +X, matching
|
||||||
|
// the scripted diagnostic motion. Quaternion order is (x, y, z, w).
|
||||||
|
inline Quat QuatFromYawPitch(float yaw, float pitch) {
|
||||||
|
const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
|
||||||
|
const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
|
||||||
|
return Quat{cy * sp, sy * cp, -sy * sp, cy * cp};
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Quat QuatFromAxisAngle(const Vec3& axis, float angle) {
|
||||||
|
const float half = angle * 0.5f;
|
||||||
|
const float s = std::sin(half);
|
||||||
|
return Quat{axis.x * s, axis.y * s, axis.z * s, std::cos(half)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-eye FOV as the runtime reports it: angleLeft, angleRight, angleUp,
|
||||||
|
// angleDown in radians, each signed relative to the eye's forward axis.
|
||||||
|
struct EyeFov {
|
||||||
|
float angle_left = 0.f;
|
||||||
|
float angle_right = 0.f;
|
||||||
|
float angle_up = 0.f;
|
||||||
|
float angle_down = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The guest state stores projection tangents, not angles. The 2998 layout is
|
||||||
|
// up, down, left, right in that order; down/left are negated because the
|
||||||
|
// guest's tangent space measures them in the opposite direction. Getting this
|
||||||
|
// order or a sign wrong flips or shears the image, so it is pinned by test.
|
||||||
|
struct ProjectionTangents {
|
||||||
|
float up = 0.f;
|
||||||
|
float down = 0.f;
|
||||||
|
float left = 0.f;
|
||||||
|
float right = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline ProjectionTangents FovToTangents(const EyeFov& fov) {
|
||||||
|
return ProjectionTangents{std::tan(fov.angle_up), std::tan(-fov.angle_down),
|
||||||
|
std::tan(-fov.angle_left), std::tan(fov.angle_right)};
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr float kMaxEyeFovAngle = 1.56f;
|
||||||
|
|
||||||
|
// Enclose the display frustum in a head-parallel camera for the guest renderer.
|
||||||
|
// The compositor receives this rendered FOV and pose, not the canted display's.
|
||||||
|
inline bool ParallelEyeFov(const Quat& eye_orientation, const EyeFov& display,
|
||||||
|
EyeFov* rendered) {
|
||||||
|
Quat orientation;
|
||||||
|
if (!rendered || !QuatNormalize(eye_orientation, &orientation) ||
|
||||||
|
!(display.angle_left < display.angle_right) ||
|
||||||
|
!(display.angle_down < display.angle_up)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float angles[] = {display.angle_left, display.angle_right,
|
||||||
|
display.angle_up, display.angle_down};
|
||||||
|
for (float angle : angles) {
|
||||||
|
if (!std::isfinite(angle) || std::abs(angle) >= kMaxEyeFovAngle)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (orientation.x == 0.f && orientation.y == 0.f && orientation.z == 0.f) {
|
||||||
|
*rendered = display;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const float xs[] = {std::tan(display.angle_left),
|
||||||
|
std::tan(display.angle_right)};
|
||||||
|
const float ys[] = {std::tan(display.angle_down), std::tan(display.angle_up)};
|
||||||
|
float left = 0.f, right = 0.f, down = 0.f, up = 0.f;
|
||||||
|
bool first = true;
|
||||||
|
for (float x : xs) {
|
||||||
|
for (float y : ys) {
|
||||||
|
const Vec3 ray = QuatRotate(orientation, {x, y, -1.f});
|
||||||
|
if (!std::isfinite(ray.z) || ray.z >= -1e-6f) return false;
|
||||||
|
const float horizontal = ray.x / -ray.z;
|
||||||
|
const float vertical = ray.y / -ray.z;
|
||||||
|
if (!std::isfinite(horizontal) || !std::isfinite(vertical)) return false;
|
||||||
|
if (first) {
|
||||||
|
left = right = horizontal;
|
||||||
|
down = up = vertical;
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
left = std::min(left, horizontal);
|
||||||
|
right = std::max(right, horizontal);
|
||||||
|
down = std::min(down, vertical);
|
||||||
|
up = std::max(up, vertical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const EyeFov result{std::atan(left), std::atan(right), std::atan(up),
|
||||||
|
std::atan(down)};
|
||||||
|
const float bounds[] = {result.angle_left, result.angle_right,
|
||||||
|
result.angle_up, result.angle_down};
|
||||||
|
for (float angle : bounds) {
|
||||||
|
if (std::abs(angle) >= kMaxEyeFovAngle) return false;
|
||||||
|
}
|
||||||
|
*rendered = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metres-to-studs factor the exact-build native helper applies itself. Exposed
|
||||||
|
// only so tests can prove this layer does NOT pre-apply it.
|
||||||
|
inline constexpr float kMetresToStuds = 10.f / 3.f;
|
||||||
|
|
||||||
|
// Expected stud value for a metre offset, for numerical unit checks.
|
||||||
|
inline float MetresToStuds(float metres) { return metres * kMetresToStuds; }
|
||||||
|
|
||||||
|
} // namespace mocktail::vr::pose
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_POSE_MATH_H_
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_XR_ACTIONS_H_
|
||||||
|
#define MOCKTAIL_VR_XR_ACTIONS_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "mocktail/status.h"
|
||||||
|
#include "mocktail/vr/vr_pose_math.h"
|
||||||
|
#include "mocktail/vr/xr_controller.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// Session-owned OpenXR action layer: one action set, left/right subactions,
|
||||||
|
// grip+aim pose spaces, and interaction-profile bindings for Touch (primary),
|
||||||
|
// Quest 2, Index, Vive wands and the simple controller. All XR handles stay
|
||||||
|
// opaque here so non-OpenXR consumers (bridge, tests) need no XR headers.
|
||||||
|
//
|
||||||
|
// Frame contract: Sync() runs exactly once per frame from the backend frame
|
||||||
|
// cycle, immediately after xrWaitFrame returned the predicted display time and
|
||||||
|
// before the guest renders, so located hand poses share the head/eye frame.
|
||||||
|
// There is no second frame wait and no polling from guest getters.
|
||||||
|
class XrActions final {
|
||||||
|
public:
|
||||||
|
XrActions() = default;
|
||||||
|
~XrActions();
|
||||||
|
XrActions(const XrActions &) = delete;
|
||||||
|
XrActions &operator=(const XrActions &) = delete;
|
||||||
|
|
||||||
|
// Creates the action set/actions and suggests bindings. Instance-level:
|
||||||
|
// valid before any session exists. Failure is non-fatal for XR output; the
|
||||||
|
// backend logs and continues without controller input.
|
||||||
|
Status Create(void *xr_instance, bool enable_eye_gaze=false);
|
||||||
|
struct EyeGazePose {float orientation[4]={0,0,0,1};bool valid=false;std::uint64_t frame=0;};
|
||||||
|
const EyeGazePose& eye_gaze() const {return eye_gaze_;}
|
||||||
|
|
||||||
|
// Attaches to a session and creates the per-hand pose spaces. Must be
|
||||||
|
// called after xrCreateSession and before the first Sync; recreated on every
|
||||||
|
// session replacement (no stale handles survive).
|
||||||
|
Status Attach(void *xr_instance, void *xr_session, void *xr_local_space);
|
||||||
|
|
||||||
|
// Destroys spaces and detaches; the action set survives for reattach.
|
||||||
|
void Detach();
|
||||||
|
void Destroy();
|
||||||
|
|
||||||
|
bool attached() const { return attached_; }
|
||||||
|
|
||||||
|
// One per-frame sync at predicted_display_time. Locates grip and aim poses
|
||||||
|
// in the session's LOCAL space, reads analog/boolean states, refreshes
|
||||||
|
// connection state from the runtime's current interaction profile, and
|
||||||
|
// builds the coherent snapshot + delivery batch. Returns false when actions
|
||||||
|
// are unavailable (runtime refused sync); input is reset and a disconnect
|
||||||
|
// remains drainable even while the session is detached.
|
||||||
|
bool Sync(void *xr_session, std::uint64_t predicted_display_time,
|
||||||
|
std::uint64_t frame, std::uint64_t session_generation);
|
||||||
|
|
||||||
|
// Latest coherent snapshot (frame/session generation tagged).
|
||||||
|
const ControllerSnapshot &snapshot() const { return snapshot_; }
|
||||||
|
// Hand poses in metres/LOCAL space for the state-copy records.
|
||||||
|
const VrHandPose &hand(int index) const { return hands_[index & 1]; }
|
||||||
|
|
||||||
|
// Drains the next pending delivery batch built by Sync. Returns false when
|
||||||
|
// nothing is queued. Thread-safe: Sync runs on the render thread, the drain
|
||||||
|
// runs on the window thread. Backed by a bounded queue, so a slow consumer
|
||||||
|
// drops oldest frames and converges via resync rather than growing.
|
||||||
|
bool TakeDelivery(ControllerDelivery *out);
|
||||||
|
|
||||||
|
// Queues a vibration request for one hand. Safe from any thread; applied on
|
||||||
|
// the next Sync's XR call window. amplitude [0,1], duration in nanoseconds
|
||||||
|
// (0 = runtime default). A newer request for the same hand replaces the
|
||||||
|
// pending one. StopHaptics cancels.
|
||||||
|
void RequestHaptics(int hand, float amplitude, std::uint64_t duration_ns,
|
||||||
|
float frequency_hz);
|
||||||
|
|
||||||
|
void StopHaptics(int hand);
|
||||||
|
|
||||||
|
// Interaction-profile diagnostics (refreshed on profile-change events and
|
||||||
|
// every Sync that observes a change).
|
||||||
|
std::string ActiveProfile(int hand) const;
|
||||||
|
|
||||||
|
// Test seam: publish an externally sourced profile name (event dispatch in
|
||||||
|
// the backend) without XR calls.
|
||||||
|
void NoteProfileChanged();
|
||||||
|
|
||||||
|
// Called by the backend on focus/session loss, under controller ownership.
|
||||||
|
void ResetInput();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
Impl *impl_ = nullptr;
|
||||||
|
bool created_ = false;
|
||||||
|
bool attached_ = false;
|
||||||
|
ControllerStateBuilder builder_;
|
||||||
|
ControllerSnapshot snapshot_;
|
||||||
|
VrHandPose hands_[2];
|
||||||
|
EyeGazePose eye_gaze_;
|
||||||
|
ControllerDeliveryQueue delivery_queue_;
|
||||||
|
std::uint64_t session_generation_ = 0;
|
||||||
|
// Last connection level handed to the consumer, to mark connection changes.
|
||||||
|
bool last_delivered_connected_[2] = {false, false};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_XR_ACTIONS_H_
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_XR_CONTROLLER_H_
|
||||||
|
#define MOCKTAIL_VR_XR_CONTROLLER_H_
|
||||||
|
|
||||||
|
#include "mocktail/vr/xr_controller_abi.h"
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// One tracked controller hand, located at the same predicted display time as
|
||||||
|
// the head. Grip is the held-object pose; aim is the pointing pose. They are
|
||||||
|
// not interchangeable: the guest consumes grip for hands/tools and aim for UI
|
||||||
|
// rays. Position is metres, orientation a quaternion (x, y, z, w) in the
|
||||||
|
// OpenXR LOCAL reference space. The guest applies its own metres-to-studs
|
||||||
|
// conversion and hand pitch offset, so neither is pre-applied here.
|
||||||
|
struct VrHandPose {
|
||||||
|
float grip_position[3] = {0.f, 0.f, 0.f};
|
||||||
|
float grip_orientation[4] = {0.f, 0.f, 0.f, 1.f};
|
||||||
|
float aim_position[3] = {0.f, 0.f, 0.f};
|
||||||
|
float aim_orientation[4] = {0.f, 0.f, 0.f, 1.f};
|
||||||
|
// Pose validity, reported separately from button activity and connection so
|
||||||
|
// a controller can be connected with an untracked pose.
|
||||||
|
bool grip_valid = false;
|
||||||
|
bool aim_valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Engine-facing controller capability, independent of which OpenXR
|
||||||
|
// interaction profile supplied it. The guest receives buttons and analog axes
|
||||||
|
// through its verified Android gamepad route, so this layer normalizes every
|
||||||
|
// supported profile onto that one stable contract.
|
||||||
|
enum class ControllerButton {
|
||||||
|
kTrigger, // analog click semantics; value reported separately
|
||||||
|
kSqueeze, // grip/squeeze
|
||||||
|
kThumbstick, // stick click
|
||||||
|
kFaceNorth, // right B / left Y
|
||||||
|
kFaceSouth, // right A / left X
|
||||||
|
kFaceEast, // right B equivalent on layouts that have it
|
||||||
|
kFaceWest, // left X equivalent
|
||||||
|
kMenu, // application-accessible menu only; never a system button
|
||||||
|
kCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capacitive contacts are distinct from presses. Some runtimes/profiles do
|
||||||
|
// not expose them; active=false means unavailable, not an inferred contact.
|
||||||
|
enum class ControllerTouch {
|
||||||
|
kTrigger,
|
||||||
|
kThumbstick,
|
||||||
|
kThumbrest,
|
||||||
|
kA,
|
||||||
|
kB,
|
||||||
|
kX,
|
||||||
|
kY,
|
||||||
|
kCount
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ControllerAxis {
|
||||||
|
kTrigger, // 0..1
|
||||||
|
kSqueeze, // 0..1
|
||||||
|
kThumbstickX, // -1..1
|
||||||
|
kThumbstickY, // -1..1
|
||||||
|
kCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analog trigger/squeeze thresholds used only where the runtime exposes a
|
||||||
|
// boolean click for an analog control. Hysteresis prevents edge chattering at
|
||||||
|
// the boundary; native analog values are always preserved alongside it.
|
||||||
|
struct AnalogClickThresholds {
|
||||||
|
float press = 0.75f;
|
||||||
|
float release = 0.6f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One coherent per-frame controller snapshot. Built once per frame by the
|
||||||
|
// backend after a single xrSyncActions, then read by native getters without
|
||||||
|
// re-polling. Button booleans are level state; edge detection happens once, at
|
||||||
|
// publish time, so repeated native getters never emit duplicate edges.
|
||||||
|
struct ControllerHandState {
|
||||||
|
bool connected = false;
|
||||||
|
bool pose_valid = false;
|
||||||
|
std::array<bool, static_cast<std::size_t>(ControllerTouch::kCount)> touches{};
|
||||||
|
std::array<bool, static_cast<std::size_t>(ControllerTouch::kCount)>
|
||||||
|
touch_active{};
|
||||||
|
std::array<bool, static_cast<std::size_t>(ControllerButton::kCount)>
|
||||||
|
buttons{};
|
||||||
|
std::array<float, static_cast<std::size_t>(ControllerAxis::kCount)> axes{};
|
||||||
|
// Interaction profile actually reported by the runtime for this hand, for
|
||||||
|
// diagnostics only (never used to gate input).
|
||||||
|
std::string profile;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ControllerSnapshot {
|
||||||
|
ControllerHandState hands[2]; // 0 = left, 1 = right
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
std::uint64_t session_generation = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure state machine: maps raw OpenXR action values onto the normalized
|
||||||
|
// contract and derives edges exactly once per published frame. No OpenXR or
|
||||||
|
// JNI types, so it is unit-testable with injected values.
|
||||||
|
class ControllerStateBuilder {
|
||||||
|
public:
|
||||||
|
explicit ControllerStateBuilder(AnalogClickThresholds thresholds = {});
|
||||||
|
|
||||||
|
// Records one raw action value for the given hand. Values not set by the
|
||||||
|
// runtime are neutral when explicitly marked inactive.
|
||||||
|
void SetTouch(int hand, ControllerTouch touch, bool value, bool active);
|
||||||
|
void SetButton(int hand, ControllerButton button, bool value, bool active);
|
||||||
|
void SetAxis(int hand, ControllerAxis axis, float value, bool active);
|
||||||
|
void SetConnected(int hand, bool connected);
|
||||||
|
void SetProfile(int hand, std::string profile);
|
||||||
|
void SetPoseValid(int hand, bool valid);
|
||||||
|
|
||||||
|
// Produces the snapshot for this frame and records which buttons changed so
|
||||||
|
// the caller can emit exactly one native edge per transition. frame must
|
||||||
|
// advance monotonically; publishing the same frame twice yields no edges.
|
||||||
|
struct Edge {
|
||||||
|
int hand = 0;
|
||||||
|
ControllerButton button = ControllerButton::kTrigger;
|
||||||
|
bool pressed = false;
|
||||||
|
};
|
||||||
|
ControllerSnapshot Publish(std::uint64_t frame, std::uint64_t generation,
|
||||||
|
std::vector<Edge> *edges);
|
||||||
|
|
||||||
|
// Neutralizes all axes and releases held buttons once (focus loss,
|
||||||
|
// disconnect, session stop). Produces release edges for anything held so no
|
||||||
|
// button can stay stuck down.
|
||||||
|
void ReleaseAll(std::uint64_t frame, std::uint64_t generation,
|
||||||
|
std::vector<Edge> *edges);
|
||||||
|
|
||||||
|
// Derives the click state for an analog control with hysteresis: latches on
|
||||||
|
// at `press`, off at `release`. Call after SetAxis for the same hand. Used
|
||||||
|
// for profiles that expose a trigger value without a click action; native
|
||||||
|
// analog values are always preserved regardless.
|
||||||
|
bool AnalogClick(int hand, ControllerAxis axis);
|
||||||
|
|
||||||
|
const ControllerSnapshot &snapshot() const { return snapshot_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
AnalogClickThresholds thresholds_;
|
||||||
|
ControllerSnapshot snapshot_;
|
||||||
|
// Last published level per hand/button, used for edge detection.
|
||||||
|
std::array<
|
||||||
|
std::array<bool, static_cast<std::size_t>(ControllerButton::kCount)>, 2>
|
||||||
|
published_buttons_{};
|
||||||
|
// Hysteresis latch per (hand, analog control): [hand*2 + axis].
|
||||||
|
std::array<bool, 4> analog_latch_{};
|
||||||
|
bool have_published_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps the normalized contract onto the guest's verified Android gamepad
|
||||||
|
// identifiers, so XR controllers reuse the single authoritative input route
|
||||||
|
// and cannot double-fire alongside a physical SDL gamepad. hand selects the
|
||||||
|
// per-hand squeeze/thumbstick/trigger identifiers (0 = left, 1 = right).
|
||||||
|
// Returns -1 when the control has no keycode (analog click).
|
||||||
|
int32_t ControllerButtonToAndroidKey(int hand, ControllerButton button);
|
||||||
|
// Returns the Android MotionEvent axis id for one axis of the given hand
|
||||||
|
// (left/right trigger differ), or -1 when the control has no analog axis.
|
||||||
|
int32_t ControllerAxisToAndroidAxis(int hand, ControllerAxis axis);
|
||||||
|
// How many float components the guest expects for this axis (sticks 2, triggers
|
||||||
|
// 1).
|
||||||
|
int32_t ControllerAxisComponentCount(ControllerAxis axis);
|
||||||
|
|
||||||
|
// SDL3 gamepad ordinals. XR controller delivery reuses the existing router
|
||||||
|
// (RobloxInputRouter::HandleEvent) as the single authoritative event route:
|
||||||
|
// the router performs the verified SDL->Android key/axis conversion, level
|
||||||
|
// deduplication, focus gating and release-on-disconnect itself. Synthesized
|
||||||
|
// platform events must therefore carry SDL ordinals, not Android codes.
|
||||||
|
// hand selects the per-hand squeeze/thumbstick/trigger ordinals.
|
||||||
|
int32_t ControllerButtonToSdlOrdinal(int hand, ControllerButton button);
|
||||||
|
// SDL axis ordinal per hand for stick/trigger delivery; -1 when not an axis.
|
||||||
|
// Left hand: LEFTX/LEFTY/TRIGGERLEFT; right: RIGHTX/RIGHTY/TRIGGERRIGHT.
|
||||||
|
int32_t ControllerAxisToSdlOrdinal(int hand, ControllerAxis axis);
|
||||||
|
|
||||||
|
// One drained delivery batch from the XR backend to the input route. Built
|
||||||
|
// once per frame after xrSyncActions; edges appear exactly once. `resync`
|
||||||
|
// requests full level re-delivery (recovery after queue overflow or an
|
||||||
|
// unfocused drop) — the router deduplicates, so re-delivery is idempotent and
|
||||||
|
// guarantees no button sticks down.
|
||||||
|
struct ControllerDelivery {
|
||||||
|
std::uint64_t session_generation = 0;
|
||||||
|
bool button_levels[2][static_cast<std::size_t>(ControllerButton::kCount)] =
|
||||||
|
{};
|
||||||
|
bool connected[2] = {false, false};
|
||||||
|
bool connection_changed[2] = {false, false};
|
||||||
|
struct ButtonEdge {
|
||||||
|
int hand = 0;
|
||||||
|
ControllerButton button = ControllerButton::kTrigger;
|
||||||
|
bool pressed = false;
|
||||||
|
};
|
||||||
|
std::vector<ButtonEdge> edges;
|
||||||
|
// SDL ordinal order: LEFTX, LEFTY, RIGHTX, RIGHTY, TRIGGERLEFT, TRIGGERRIGHT.
|
||||||
|
float axes[6] = {0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
|
||||||
|
bool axes_valid[2] = {false, false};
|
||||||
|
bool resync = false;
|
||||||
|
std::uint64_t frame = 0;
|
||||||
|
|
||||||
|
// Bounded growth guard: the backend caps the edge queue and sets resync
|
||||||
|
// instead of growing without limit.
|
||||||
|
static constexpr std::size_t kMaxEdges = 64;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool EncodeNativeControllerChannels(const ControllerSnapshot &snapshot,
|
||||||
|
float (&channels)[28]);
|
||||||
|
|
||||||
|
void EncodeControllerDelivery(const ControllerDelivery &delivery,
|
||||||
|
MocktailVrControllerDelivery *out);
|
||||||
|
|
||||||
|
// Thread-safe bounded queue between the XR render-thread producer (one push
|
||||||
|
// per frame after xrSyncActions) and the window-thread consumer that drains
|
||||||
|
// into the verified input route. Overflow drops the oldest frames and arms a
|
||||||
|
// resync on the next delivered frame, so the consumer always converges to the
|
||||||
|
// current levels and a full queue can never leave a button stuck down.
|
||||||
|
class ControllerDeliveryQueue {
|
||||||
|
public:
|
||||||
|
static constexpr std::size_t kCapacity = 8;
|
||||||
|
|
||||||
|
void Push(ControllerDelivery delivery);
|
||||||
|
bool Pop(ControllerDelivery *out);
|
||||||
|
std::size_t size() const;
|
||||||
|
bool overflowed() const { return overflowed_; }
|
||||||
|
std::uint64_t dropped_frames() const { return dropped_frames_; }
|
||||||
|
void Clear();
|
||||||
|
|
||||||
|
private:
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
std::deque<ControllerDelivery> queue_;
|
||||||
|
bool overflowed_ = false;
|
||||||
|
std::uint64_t dropped_frames_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mocktail::vr
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_VR_XR_CONTROLLER_H_
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#ifndef MOCKTAIL_VR_XR_CONTROLLER_ABI_H_
|
||||||
|
#define MOCKTAIL_VR_XR_CONTROLLER_ABI_H_
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/* Plain-C mirror of mocktail::vr::ControllerDelivery for crossing the
|
||||||
|
* dlsym module boundary (XR backend -> input runtime) without exporting C++
|
||||||
|
* types or requiring the consumer to link OpenXR. Field order and types are
|
||||||
|
* the ABI; both sides include this header. Keep it POD: no constructors, no
|
||||||
|
* std, fixed arrays only. */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define MOCKTAIL_VR_CONTROLLER_MAX_EDGES 64
|
||||||
|
#define MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT 8
|
||||||
|
#define MOCKTAIL_VR_CONTROLLER_AXIS_COUNT 6
|
||||||
|
|
||||||
|
/* Button indices: mirror mocktail::vr::ControllerButton enum order. */
|
||||||
|
enum {
|
||||||
|
MOCKTAIL_VR_BUTTON_TRIGGER = 0,
|
||||||
|
MOCKTAIL_VR_BUTTON_SQUEEZE = 1,
|
||||||
|
MOCKTAIL_VR_BUTTON_THUMBSTICK = 2,
|
||||||
|
MOCKTAIL_VR_BUTTON_FACE_NORTH = 3,
|
||||||
|
MOCKTAIL_VR_BUTTON_FACE_SOUTH = 4,
|
||||||
|
MOCKTAIL_VR_BUTTON_FACE_EAST = 5,
|
||||||
|
MOCKTAIL_VR_BUTTON_FACE_WEST = 6,
|
||||||
|
MOCKTAIL_VR_BUTTON_MENU = 7
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct MocktailVrControllerEdge {
|
||||||
|
int32_t hand; /* 0 = left, 1 = right */
|
||||||
|
int32_t button; /* MOCKTAIL_VR_BUTTON_* */
|
||||||
|
int32_t pressed; /* 0/1 */
|
||||||
|
} MocktailVrControllerEdge;
|
||||||
|
|
||||||
|
typedef struct MocktailVrControllerDelivery {
|
||||||
|
uint64_t frame;
|
||||||
|
uint64_t session_generation;
|
||||||
|
int32_t connected[2];
|
||||||
|
int32_t connection_changed[2];
|
||||||
|
/* Current button levels per hand (MOCKTAIL_VR_BUTTON_* order). Used for
|
||||||
|
* resync after queue overflow: the consumer re-delivers every level and the
|
||||||
|
* router's deduplication makes it idempotent. */
|
||||||
|
int32_t button_levels[2][MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT];
|
||||||
|
/* Analog levels in SDL gamepad axis order:
|
||||||
|
* 0=LEFTX 1=LEFTY 2=RIGHTX 3=RIGHTY 4=TRIGGERLEFT 5=TRIGGERRIGHT. */
|
||||||
|
float axes[MOCKTAIL_VR_CONTROLLER_AXIS_COUNT];
|
||||||
|
int32_t axes_valid[2];
|
||||||
|
int32_t resync;
|
||||||
|
int32_t edge_count;
|
||||||
|
MocktailVrControllerEdge edges[MOCKTAIL_VR_CONTROLLER_MAX_EDGES];
|
||||||
|
} MocktailVrControllerDelivery;
|
||||||
|
|
||||||
|
/* Pops the next delivery batch produced by the XR backend's once-per-frame
|
||||||
|
* action sync. Returns 1 and fills *out when a batch was queued, 0 when the
|
||||||
|
* queue is empty or no XR controller layer is active. Inert (returns 0) in
|
||||||
|
* non-VR builds and while no backend is armed. */
|
||||||
|
typedef int32_t (*MocktailVrControllerPopFn)(MocktailVrControllerDelivery *out);
|
||||||
|
|
||||||
|
/* Queues a vibration request for one hand (0=left, 1=right). amplitude is
|
||||||
|
* clamped to [0,1]; duration_ns 0 means the runtime default. Safe from any
|
||||||
|
* thread; applied on the next XR sync. No-op when no controller layer is
|
||||||
|
* active. */
|
||||||
|
typedef void (*MocktailVrControllerHapticsFn)(int32_t hand, float amplitude,
|
||||||
|
uint64_t duration_ns,
|
||||||
|
float frequency_hz);
|
||||||
|
typedef void (*MocktailVrControllerStopHapticsFn)(int32_t hand);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* MOCKTAIL_VR_XR_CONTROLLER_ABI_H_ */
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <vulkan/vulkan.h>
|
||||||
|
#include "mocktail/status.h"
|
||||||
|
|
||||||
|
namespace mocktail::vr {
|
||||||
|
|
||||||
|
// Runtime-owned eye-tracked density maps. The unused control color images are
|
||||||
|
// never submitted; ordinary output swapchains retain their transfer-copy path.
|
||||||
|
// Caller serializes access and completes GPU use before EndFrame/Destroy.
|
||||||
|
class XrFoveationMaps {
|
||||||
|
public:
|
||||||
|
XrFoveationMaps();
|
||||||
|
~XrFoveationMaps();
|
||||||
|
XrFoveationMaps(const XrFoveationMaps&) = delete;
|
||||||
|
XrFoveationMaps& operator=(const XrFoveationMaps&) = delete;
|
||||||
|
Status Initialize(void* instance, std::uint64_t system, void* session,
|
||||||
|
VkDevice device, PFN_vkGetDeviceProcAddr get_device_proc,
|
||||||
|
std::uint32_t width, std::uint32_t height, VkFormat format);
|
||||||
|
bool BeginFrame();
|
||||||
|
void EndFrame();
|
||||||
|
void Destroy();
|
||||||
|
bool ready() const;
|
||||||
|
bool acquired() const;
|
||||||
|
bool tracking() const;
|
||||||
|
VkImageView view(int eye) const;
|
||||||
|
std::uint32_t width() const;
|
||||||
|
std::uint32_t height() const;
|
||||||
|
bool center(int eye, float* x, float* y) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
} // namespace mocktail::vr
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#ifndef MOCKTAIL_RUNTIME_COMMAND_LINE_H_
|
#ifndef MOCKTAIL_RUNTIME_COMMAND_LINE_H_
|
||||||
#define MOCKTAIL_RUNTIME_COMMAND_LINE_H_
|
#define MOCKTAIL_RUNTIME_COMMAND_LINE_H_
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ enum class WindowMode {
|
|||||||
struct CommandLineOptions {
|
struct CommandLineOptions {
|
||||||
CommandMode mode = CommandMode::kRun;
|
CommandMode mode = CommandMode::kRun;
|
||||||
WindowMode window_mode = WindowMode::kUnspecified;
|
WindowMode window_mode = WindowMode::kUnspecified;
|
||||||
|
std::optional<bool> vr_enabled;
|
||||||
std::string program_name = "mocktail";
|
std::string program_name = "mocktail";
|
||||||
std::string roblox_library_path;
|
std::string roblox_library_path;
|
||||||
std::string graphics_backend;
|
std::string graphics_backend;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#ifndef MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
|
||||||
|
#define MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "runtime/roblox_experience_presence.h"
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
// A user value in fflags.json or the overrides environment disables switching.
|
||||||
|
// ARM64 prepares the writable flag before enabling menu code generation.
|
||||||
|
inline constexpr std::string_view kLuauCodegenAllClientSetting =
|
||||||
|
"FFlagDebugLuauCodegenAll";
|
||||||
|
inline constexpr std::string_view kLuauCodegenAllFastFlagName =
|
||||||
|
"DebugLuauCodegenAll";
|
||||||
|
|
||||||
|
struct LoadedImageSegment {
|
||||||
|
std::uintptr_t address = 0;
|
||||||
|
std::size_t size = 0;
|
||||||
|
bool writable = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<LoadedImageSegment> LoadedImageSegments(std::uintptr_t image_base);
|
||||||
|
|
||||||
|
// Record layout: {u8 value; pad; const char* name; size_t length}, in a
|
||||||
|
// writable segment filled by libroblox's static constructors. Returns null
|
||||||
|
// unless exactly one record names a NUL-terminated copy of `name`.
|
||||||
|
std::uint8_t* FindFastFlagValue(
|
||||||
|
const std::vector<LoadedImageSegment>& segments, std::string_view name);
|
||||||
|
|
||||||
|
bool ClientSettingsSetLuauCodegenAll(std::string_view overrides_json);
|
||||||
|
|
||||||
|
bool LuauCodegenAllForPhase(RobloxExperiencePresencePhase phase);
|
||||||
|
|
||||||
|
// Locates the flag once; a missing/ambiguous record or non-boolean byte is left
|
||||||
|
// alone. Automatic ARM64 startup must not force the flag on before preparation.
|
||||||
|
class LuauCodegenMenuSwitch {
|
||||||
|
public:
|
||||||
|
using LocateFlagFn = std::function<std::uint8_t*()>;
|
||||||
|
|
||||||
|
LuauCodegenMenuSwitch(LocateFlagFn locate_flag,
|
||||||
|
RobloxExperiencePresenceObserver next);
|
||||||
|
|
||||||
|
// Call after constructors/settings initialization, before starting LuaApp.
|
||||||
|
// Does not notify the downstream presence observer. False leaves it unchanged.
|
||||||
|
bool PrepareForMenus();
|
||||||
|
RobloxExperiencePresenceObserver observer();
|
||||||
|
void OnPhase(RobloxExperiencePresencePhase phase,
|
||||||
|
const RobloxExperienceLaunchRequest* request);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static void Notify(void* context, RobloxExperiencePresencePhase phase,
|
||||||
|
const RobloxExperienceLaunchRequest* request);
|
||||||
|
bool SetEnabledLocked(bool enabled);
|
||||||
|
|
||||||
|
std::mutex mutex_;
|
||||||
|
LocateFlagFn locate_flag_;
|
||||||
|
bool located_ = false;
|
||||||
|
std::uint8_t* flag_ = nullptr;
|
||||||
|
const RobloxExperiencePresenceObserver next_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
|
||||||
@@ -64,6 +64,13 @@ bool MergeRuntimeClientSettingsOverrides(
|
|||||||
std::string_view base_json, std::string* merged_json, std::string* error,
|
std::string_view base_json, std::string* merged_json, std::string* error,
|
||||||
std::vector<std::string>* kept_overrides = nullptr);
|
std::vector<std::string>* kept_overrides = nullptr);
|
||||||
|
|
||||||
|
// The resolved VR mode owns these flags in both client-settings channels.
|
||||||
|
// Temporal depth preservation also requires non-memoryless native targets.
|
||||||
|
bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json,
|
||||||
|
std::string* merged_json,
|
||||||
|
std::string* error,
|
||||||
|
bool preserve_temporal_depth = false);
|
||||||
|
|
||||||
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
|
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
|
||||||
// answers microphone authorization from capture configuration. No permission
|
// answers microphone authorization from capture configuration. No permission
|
||||||
// query opens a recording device.
|
// query opens a recording device.
|
||||||
|
|||||||
@@ -36,7 +36,18 @@ DesktopAppPolicyResult ApplyDesktopAppPolicy(
|
|||||||
bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
|
bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
|
||||||
std::string_view base_json,
|
std::string_view base_json,
|
||||||
std::string* merged_json,
|
std::string* merged_json,
|
||||||
std::string* error);
|
std::string* error,
|
||||||
|
bool default_codegen_at_startup = true);
|
||||||
|
|
||||||
|
// Apply after desktop policy composition. Roblox 2998's Android payload is not
|
||||||
|
// a VRAppBuild, so its LuaApp enables RunService's idle render throttle
|
||||||
|
// even with DebugDeviceVR active. Preserve the effective app-policy (the later
|
||||||
|
// fast-flags channel takes precedence) in both channels, changing only
|
||||||
|
// ThrottleFramerate. This does not write the setting to appStorage.
|
||||||
|
bool MergeVrAppPolicyClientSettingsOverrides(
|
||||||
|
bool enabled, std::string_view client_settings_json,
|
||||||
|
std::string_view fast_flags_json, std::string* merged_client_settings,
|
||||||
|
std::string* merged_fast_flags, std::string* error);
|
||||||
|
|
||||||
} // namespace runtime
|
} // namespace runtime
|
||||||
} // namespace mocktail
|
} // namespace mocktail
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#include "runtime/roblox_experience_launch_bridge.h"
|
#include "runtime/roblox_experience_launch_bridge.h"
|
||||||
#include "runtime/roblox_experience_presence.h"
|
#include "runtime/roblox_experience_presence.h"
|
||||||
#include "runtime/roblox_fresh_game_launch_controller.h"
|
#include "runtime/roblox_fresh_game_launch_controller.h"
|
||||||
|
#include "runtime/roblox_haptic_protocol_bridge.h"
|
||||||
#include "runtime/roblox_account_bridge.h"
|
#include "runtime/roblox_account_bridge.h"
|
||||||
#include "runtime/roblox_permissions_bridge.h"
|
#include "runtime/roblox_permissions_bridge.h"
|
||||||
#include "runtime/roblox_web_view_bridge.h"
|
#include "runtime/roblox_web_view_bridge.h"
|
||||||
@@ -260,6 +261,7 @@ class RobloxExperienceComposition final {
|
|||||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||||
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
|
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
|
||||||
|
std::unique_ptr<RobloxHapticProtocolBridge> haptic_protocol_bridge_;
|
||||||
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
||||||
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
||||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#ifndef MOCKTAIL_RUNTIME_ROBLOX_HAPTIC_PROTOCOL_BRIDGE_H_
|
||||||
|
#define MOCKTAIL_RUNTIME_ROBLOX_HAPTIC_PROTOCOL_BRIDGE_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
using HapticSinkFn = std::function<void(int hand, float amplitude,
|
||||||
|
uint64_t duration_ns, float frequency_hz)>;
|
||||||
|
using HapticStopFn = std::function<void(int hand)>;
|
||||||
|
using HapticStateSyncFn = std::function<void(JNIEnv* env)>;
|
||||||
|
|
||||||
|
class HapticWaveformPlayer;
|
||||||
|
|
||||||
|
class RobloxHapticProtocolBridge final {
|
||||||
|
public:
|
||||||
|
RobloxHapticProtocolBridge(
|
||||||
|
JniEnvironmentProvider environment,
|
||||||
|
RobloxMessageBusSymbols symbols,
|
||||||
|
RobloxMessageBusObjects objects,
|
||||||
|
HapticSinkFn haptic_sink = nullptr,
|
||||||
|
HapticStopFn haptic_stop = nullptr,
|
||||||
|
HapticStateSyncFn haptic_state_sync = nullptr);
|
||||||
|
~RobloxHapticProtocolBridge();
|
||||||
|
|
||||||
|
Status Initialize();
|
||||||
|
Status Shutdown();
|
||||||
|
|
||||||
|
static std::string GetMotorsJson();
|
||||||
|
// True during a VR session; main() sets MOCKTAIL_VR_ENABLED to 0 when VR
|
||||||
|
// cannot start, so the bridge stays off for desktop play.
|
||||||
|
static bool EnabledForThisSession();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::shared_ptr<HapticWaveformPlayer> player_;
|
||||||
|
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
|
#endif
|
||||||
@@ -89,6 +89,12 @@ struct RobloxTextDisplaySink {
|
|||||||
UpdateFn update = nullptr;
|
UpdateFn update = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Process-wide second observer of the focused TextBox display, called
|
||||||
|
// synchronously after the surface overlay with the same sensitivity contract.
|
||||||
|
// The VR keyboard uses it to show the text being typed in the headset, where
|
||||||
|
// the desktop overlay is not visible. Passing an empty sink clears it.
|
||||||
|
void SetRobloxTextDisplayMirror(RobloxTextDisplaySink sink);
|
||||||
|
|
||||||
struct RobloxTextSink {
|
struct RobloxTextSink {
|
||||||
using SyncFn = Status (*)(void* context, const char* utf8, std::size_t size,
|
using SyncFn = Status (*)(void* context, const char* utf8, std::size_t size,
|
||||||
int32_t cursor_utf16);
|
int32_t cursor_utf16);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace mocktail {
|
namespace mocktail {
|
||||||
namespace runtime {
|
namespace runtime {
|
||||||
@@ -10,8 +11,10 @@ namespace runtime {
|
|||||||
// Android's RbxKeyboard resolves the Roblox Font enum through
|
// Android's RbxKeyboard resolves the Roblox Font enum through
|
||||||
// assets/android/fonts/font-mappings.json. The selected file lives below
|
// assets/android/fonts/font-mappings.json. The selected file lives below
|
||||||
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
|
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
|
||||||
|
// Emoji come from the color fonts Roblox ships next to the mapped faces.
|
||||||
struct RobloxTextFontSelection {
|
struct RobloxTextFontSelection {
|
||||||
std::string primary_file;
|
std::string primary_file;
|
||||||
|
std::vector<std::string> emoji_files;
|
||||||
float size_scale = 0.795F;
|
float size_scale = 0.795F;
|
||||||
bool exact_mapping = false;
|
bool exact_mapping = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "mocktail/status.h"
|
#include "mocktail/status.h"
|
||||||
#include "runtime/roblox_input_native_adapter.h"
|
#include "runtime/roblox_input_native_adapter.h"
|
||||||
#include "runtime/roblox_text_surface_overlay.h"
|
#include "runtime/roblox_text_surface_overlay.h"
|
||||||
|
#include "runtime/roblox_xr_controller_input.h"
|
||||||
|
|
||||||
namespace mocktail {
|
namespace mocktail {
|
||||||
namespace runtime {
|
namespace runtime {
|
||||||
@@ -35,20 +36,40 @@ class RobloxWindowInputRuntime final {
|
|||||||
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
|
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
|
||||||
Status UpdateTextFocusProperties(uint64_t generation,
|
Status UpdateTextFocusProperties(uint64_t generation,
|
||||||
const RobloxTextFocusProperties& properties);
|
const RobloxTextFocusProperties& properties);
|
||||||
|
// Feeds a synthetic platform event through the same authoritative router as
|
||||||
|
// real SDL events. Used by the XR controller delivery path so tracked
|
||||||
|
// controllers reuse the verified gamepad route (single event source, router
|
||||||
|
// deduplication and focus gating) instead of a second JNI input path.
|
||||||
|
void InjectPlatformEvent(const platform::PlatformEvent& event);
|
||||||
RobloxInputSnapshot Snapshot() const;
|
RobloxInputSnapshot Snapshot() const;
|
||||||
void SetRawMouseEnabled(bool enabled);
|
void SetRawMouseEnabled(bool enabled);
|
||||||
|
|
||||||
|
// Drains one batch of XR tracked-controller input into the same router.
|
||||||
|
// Called once per main-loop iteration on the input thread; inert in non-VR
|
||||||
|
// builds or while no OpenXR controller layer is active.
|
||||||
|
void DrainXrControllers();
|
||||||
|
// Routes an engine/experience vibration request to the active XR hand.
|
||||||
|
void RequestXrControllerHaptics(int hand, float amplitude,
|
||||||
|
std::uint64_t duration_ns, float frequency_hz);
|
||||||
|
void StopXrControllerHaptics(int hand);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static void PlatformEventCallback(void* context,
|
static void PlatformEventCallback(void* context,
|
||||||
const platform::PlatformEvent& event);
|
const platform::PlatformEvent& event);
|
||||||
static bool MouseLockQueryCallback(void* context, bool* locked_center);
|
static bool MouseLockQueryCallback(void* context, bool* locked_center);
|
||||||
static void GamepadEventCallback(void* context,
|
static void GamepadEventCallback(void* context,
|
||||||
const platform::PlatformEvent& event);
|
const platform::PlatformEvent& event);
|
||||||
|
static void XrControllerEventCallback(void* context,
|
||||||
|
const platform::PlatformEvent& event);
|
||||||
|
|
||||||
RobloxTextSurfaceOverlay text_surface_overlay_;
|
RobloxTextSurfaceOverlay text_surface_overlay_;
|
||||||
std::unique_ptr<platform::TextClipboard> text_clipboard_;
|
std::unique_ptr<platform::TextClipboard> text_clipboard_;
|
||||||
RobloxInputRuntime runtime_;
|
RobloxInputRuntime runtime_;
|
||||||
platform::SdlGamepadManager gamepads_;
|
platform::SdlGamepadManager gamepads_;
|
||||||
|
// XR tracked-controller producer. Feeds the same runtime_ router as SDL so
|
||||||
|
// there is exactly one authoritative input route.
|
||||||
|
RobloxXrControllerInput xr_controllers_;
|
||||||
|
bool xr_controllers_initialized_ = false;
|
||||||
bool observer_registered_ = false;
|
bool observer_registered_ = false;
|
||||||
bool mouse_lock_query_registered_ = false;
|
bool mouse_lock_query_registered_ = false;
|
||||||
bool initialized_ = false;
|
bool initialized_ = false;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#ifndef MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
|
||||||
|
#define MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "mocktail/platform/platform_runtime.h"
|
||||||
|
#include "mocktail/status.h"
|
||||||
|
#include "mocktail/vr/xr_controller_abi.h"
|
||||||
|
|
||||||
|
namespace mocktail {
|
||||||
|
namespace runtime {
|
||||||
|
|
||||||
|
// Delivers OpenXR tracked-controller input through the one authoritative
|
||||||
|
// Roblox input route (RobloxInputRouter::HandleEvent), the same verified path
|
||||||
|
// physical SDL gamepads use. Because both sources converge on that single
|
||||||
|
// route, the router's own level deduplication, focus gating and
|
||||||
|
// release-on-disconnect logic apply unchanged and an XR controller can never
|
||||||
|
// double-fire alongside a physical gamepad.
|
||||||
|
//
|
||||||
|
// Both hands are merged into ONE synthetic gamepad: the left hand supplies
|
||||||
|
// LEFTX/LEFTY, the left shoulder (squeeze) and left stick click plus X/Y, the
|
||||||
|
// right hand supplies RIGHTX/RIGHTY, the right shoulder, right stick click and
|
||||||
|
// A/B. That matches how Roblox reads VR controller buttons through
|
||||||
|
// GamepadService, and it means the guest sees a single normal controller
|
||||||
|
// rather than two competing devices.
|
||||||
|
//
|
||||||
|
// The XR backend is resolved through dlsym so this module links and runs in
|
||||||
|
// non-VR builds and while no OpenXR backend is armed; then every drain is a
|
||||||
|
// no-op. No OpenXR headers are required here.
|
||||||
|
class RobloxXrControllerInput final {
|
||||||
|
public:
|
||||||
|
using EventSink = void (*)(void *context,
|
||||||
|
const platform::PlatformEvent &event);
|
||||||
|
|
||||||
|
// Synthetic device identity. Deliberately far outside the SDL joystick id
|
||||||
|
// range so it can never collide with a physical controller.
|
||||||
|
static constexpr std::int64_t kInstanceLeft =
|
||||||
|
static_cast<std::int64_t>(0x7FFFFFFFFFFFFFF0LL);
|
||||||
|
|
||||||
|
RobloxXrControllerInput() = default;
|
||||||
|
~RobloxXrControllerInput() = default;
|
||||||
|
RobloxXrControllerInput(const RobloxXrControllerInput &) = delete;
|
||||||
|
RobloxXrControllerInput &operator=(const RobloxXrControllerInput &) = delete;
|
||||||
|
|
||||||
|
// Resolves the XR controller ABI. Returns false (and stays inert) when the
|
||||||
|
// symbols are absent, which is the normal non-VR or no-backend case; that is
|
||||||
|
// not an error condition.
|
||||||
|
bool Initialize(EventSink sink, void *context);
|
||||||
|
// Test/injection seam: binds explicit ABI function pointers instead of
|
||||||
|
// resolving them through dlsym. A null pop makes the module inert, matching
|
||||||
|
// the non-VR case. Production callers use Initialize().
|
||||||
|
bool InitializeWithAbi(EventSink sink, void *context,
|
||||||
|
MocktailVrControllerPopFn pop,
|
||||||
|
MocktailVrControllerHapticsFn haptics,
|
||||||
|
MocktailVrControllerStopHapticsFn stop_haptics);
|
||||||
|
void Shutdown();
|
||||||
|
bool active() const { return pop_ != nullptr; }
|
||||||
|
|
||||||
|
// Drains every queued delivery and forwards it as platform events. Call once
|
||||||
|
// per main-loop iteration, after window::PumpEvents(), on the same thread
|
||||||
|
// that delivers SDL input. Bounded: drains at most kMaxDrainPerPump batches.
|
||||||
|
void Drain();
|
||||||
|
void ResendState() { force_resync_ = true; }
|
||||||
|
|
||||||
|
// Forwards a vibration request to the XR hand output. hand: 0 left, 1 right.
|
||||||
|
// No-op when the XR layer is inactive. amplitude is clamped by the backend.
|
||||||
|
void RequestHaptics(int hand, float amplitude, std::uint64_t duration_ns,
|
||||||
|
float frequency_hz);
|
||||||
|
void StopHaptics(int hand);
|
||||||
|
|
||||||
|
// Last observed state, for diagnostics/tests.
|
||||||
|
bool connected() const { return connected_; }
|
||||||
|
std::uint64_t delivered_events() const { return delivered_events_; }
|
||||||
|
std::uint64_t resyncs() const { return resyncs_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr int kMaxDrainPerPump = 4;
|
||||||
|
void EmitConnection(bool connected);
|
||||||
|
void EmitButton(int sdl_button, bool pressed);
|
||||||
|
void EmitAxis(int sdl_axis, std::int16_t value);
|
||||||
|
|
||||||
|
void *context_ = nullptr;
|
||||||
|
EventSink sink_ = nullptr;
|
||||||
|
void *pop_ = nullptr;
|
||||||
|
void *haptics_ = nullptr;
|
||||||
|
void *stop_haptics_ = nullptr;
|
||||||
|
bool force_resync_ = false;
|
||||||
|
std::uint64_t generation_ = 0;
|
||||||
|
bool connected_ = false;
|
||||||
|
bool initialized_ = false;
|
||||||
|
std::uint64_t delivered_events_ = 0;
|
||||||
|
std::uint64_t resyncs_ = 0;
|
||||||
|
// Last delivered button levels, for resync and to avoid redundant events.
|
||||||
|
bool last_buttons_[16] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace runtime
|
||||||
|
} // namespace mocktail
|
||||||
|
|
||||||
|
#endif // MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
|
||||||
@@ -16,6 +16,15 @@
|
|||||||
namespace mocktail {
|
namespace mocktail {
|
||||||
namespace runtime {
|
namespace runtime {
|
||||||
|
|
||||||
|
// VR is opt-in in every build (--vr or vr.enabled: true). A VR-capable build
|
||||||
|
// must not try to reach an OpenXR runtime on a normal desktop launch.
|
||||||
|
inline constexpr bool kDefaultVrEnabled = false;
|
||||||
|
|
||||||
|
// vr.mode: on and off force VR or the desktop. auto starts in VR only when
|
||||||
|
// SteamVR is running with a headset, which main() checks before launch.
|
||||||
|
enum class VrMode { kOff, kOn, kAuto };
|
||||||
|
const char* VrModeName(VrMode mode);
|
||||||
|
|
||||||
enum class GraphicsBackend {
|
enum class GraphicsBackend {
|
||||||
kAuto,
|
kAuto,
|
||||||
kSystem,
|
kSystem,
|
||||||
@@ -96,6 +105,11 @@ class RuntimeConfig {
|
|||||||
static RuntimeConfig FromEnvironment(const Environment& environment);
|
static RuntimeConfig FromEnvironment(const Environment& environment);
|
||||||
|
|
||||||
bool headless() const { return headless_; }
|
bool headless() const { return headless_; }
|
||||||
|
bool vr_enabled() const { return vr_enabled_; }
|
||||||
|
bool vr_desktop_mirror() const { return vr_desktop_mirror_; }
|
||||||
|
// MOCKTAIL_VR_ENABLED (--vr, --no-vr) wins over MOCKTAIL_VR_MODE.
|
||||||
|
VrMode vr_mode() const { return vr_mode_; }
|
||||||
|
bool vr_valid() const { return vr_valid_; }
|
||||||
const std::filesystem::path& roblox_library_path() const {
|
const std::filesystem::path& roblox_library_path() const {
|
||||||
return roblox_library_path_;
|
return roblox_library_path_;
|
||||||
}
|
}
|
||||||
@@ -121,6 +135,15 @@ class RuntimeConfig {
|
|||||||
return roblox_http_user_agent_;
|
return roblox_http_user_agent_;
|
||||||
}
|
}
|
||||||
const FrameRatePolicy& frame_rate() const { return frame_rate_; }
|
const FrameRatePolicy& frame_rate() const { return frame_rate_; }
|
||||||
|
// Replaces frame_rate() while VR is enabled. Defaults to unlimited so the
|
||||||
|
// OpenXR runtime's xrWaitFrame paces Roblox at the headset refresh rate.
|
||||||
|
const FrameRatePolicy& vr_frame_rate() const { return vr_frame_rate_; }
|
||||||
|
// vr.anti_aliasing: fxaa, smaa or off. The VR backend reads it from the
|
||||||
|
// exported MOCKTAIL_VR_ANTI_ALIASING.
|
||||||
|
const std::string& vr_anti_aliasing() const { return vr_anti_aliasing_; }
|
||||||
|
const FrameRatePolicy& active_frame_rate() const {
|
||||||
|
return vr_enabled_ ? vr_frame_rate_ : frame_rate_;
|
||||||
|
}
|
||||||
const std::string& vsync_mode() const { return vsync_mode_; }
|
const std::string& vsync_mode() const { return vsync_mode_; }
|
||||||
const PerformancePolicy& performance() const { return performance_; }
|
const PerformancePolicy& performance() const { return performance_; }
|
||||||
const std::string& audio_output_device() const {
|
const std::string& audio_output_device() const {
|
||||||
@@ -161,6 +184,10 @@ class RuntimeConfig {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
bool headless_ = false;
|
bool headless_ = false;
|
||||||
|
bool vr_enabled_ = kDefaultVrEnabled;
|
||||||
|
bool vr_desktop_mirror_ = true;
|
||||||
|
VrMode vr_mode_ = kDefaultVrEnabled ? VrMode::kOn : VrMode::kOff;
|
||||||
|
bool vr_valid_ = true;
|
||||||
std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so";
|
std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so";
|
||||||
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
|
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
|
||||||
std::string graphics_backend_name_ = "direct-vulkan";
|
std::string graphics_backend_name_ = "direct-vulkan";
|
||||||
@@ -171,6 +198,8 @@ class RuntimeConfig {
|
|||||||
bool device_profile_valid_ = true;
|
bool device_profile_valid_ = true;
|
||||||
std::optional<std::string> roblox_http_user_agent_;
|
std::optional<std::string> roblox_http_user_agent_;
|
||||||
FrameRatePolicy frame_rate_;
|
FrameRatePolicy frame_rate_;
|
||||||
|
FrameRatePolicy vr_frame_rate_{FrameRateLimitMode::kUnlimited, 0};
|
||||||
|
std::string vr_anti_aliasing_ = "fxaa";
|
||||||
std::string vsync_mode_ = "auto";
|
std::string vsync_mode_ = "auto";
|
||||||
PerformancePolicy performance_;
|
PerformancePolicy performance_;
|
||||||
std::string audio_output_device_ = "default";
|
std::string audio_output_device_ = "default";
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<!-- Modified by vii from komaruworld/mocktail. See the top-level README "About this fork". -->
|
|
||||||
# FreeBSD Linuxulator
|
|
||||||
|
|
||||||
FreeBSD Linuxulator support is experimental. I tested it on FreeBSD
|
|
||||||
15.1-RELEASE-p2 with a Fedora 44 x86_64 userspace.
|
|
||||||
|
|
||||||
FreeBSD returns the wrong filesystem type for `/proc`. Roblox treats this as a
|
|
||||||
broken install and disconnects with Error 304 after about a minute.
|
|
||||||
[`linuxulator.patch`](linuxulator.patch) makes Linuxulator return the same
|
|
||||||
value as Linux.
|
|
||||||
|
|
||||||
## Patch FreeBSD
|
|
||||||
|
|
||||||
Use a clean Git checkout that exactly matches the running FreeBSD kernel. The
|
|
||||||
tested source revision is `aadd58dddcbc78f4d5594827b46b5633552b15ce`. Run
|
|
||||||
the following commands as root.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
fetch -o /root/linuxulator.patch https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/freebsd/linuxulator.patch
|
|
||||||
cd /usr/src
|
|
||||||
git apply /root/linuxulator.patch
|
|
||||||
make -j2 kernel-toolchain
|
|
||||||
make -j2 buildkernel KERNCONF=GENERIC MODULES_OVERRIDE=linux64
|
|
||||||
```
|
|
||||||
|
|
||||||
Install only the rebuilt module. Keep a backup and reboot instead of unloading
|
|
||||||
a live Linuxulator module.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp -p /boot/kernel/linux64.ko /boot/kernel/linux64.ko.mocktail-backup
|
|
||||||
install -o root -g wheel -m 0444 /usr/obj/usr/src/amd64.amd64/sys/GENERIC/modules/usr/src/sys/modules/linux64/linux64.ko /boot/kernel/linux64.ko.mocktail-new
|
|
||||||
mv /boot/kernel/linux64.ko.mocktail-new /boot/kernel/linux64.ko
|
|
||||||
kldxref /boot/kernel
|
|
||||||
reboot
|
|
||||||
```
|
|
||||||
|
|
||||||
After reboot, verify the result from the Fedora userspace. It must print
|
|
||||||
`9fa0`.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
chroot /compat/linux stat -f -c %t /proc
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run Nightcap
|
|
||||||
|
|
||||||
You can simply run the Nightcap AppImage from the
|
|
||||||
[latest release](https://github.com/CoderDayton/nightcap/releases/latest)
|
|
||||||
inside the Fedora userspace.
|
|
||||||
|
|
||||||
## Audio
|
|
||||||
|
|
||||||
Install the ALSA OSS plugin inside the Fedora userspace.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dnf install -y alsa-lib alsa-plugins-oss
|
|
||||||
```
|
|
||||||
|
|
||||||
On the FreeBSD host, use `cat /dev/sndstat` to find the correct audio device.
|
|
||||||
The number is system-specific: it may be `/dev/dsp0`, `/dev/dsp1`,
|
|
||||||
`/dev/dsp2`, or another device. Replace `/dev/dsp3` below with yours.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cat << 'EOF' > /etc/asound.conf
|
|
||||||
pcm.!default {
|
|
||||||
type oss
|
|
||||||
device /dev/dsp3
|
|
||||||
}
|
|
||||||
ctl.!default {
|
|
||||||
type oss
|
|
||||||
device /dev/dsp3
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
Launch Nightcap with the ALSA audio driver.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
SDL_AUDIO_DRIVER=alsa ./Nightcap-x86_64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
Nightcap cannot override this check because Roblox reads `/proc` directly. The
|
|
||||||
Linuxulator patch fixes the value before Roblox sees it.
|
|
||||||
@@ -28,12 +28,16 @@
|
|||||||
</description>
|
</description>
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-home.png</image>
|
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-home.png</image>
|
||||||
<caption>Browse Roblox experiences</caption>
|
<caption>Browse Roblox experiences</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-gameplay-tower.png</image>
|
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-tower.png</image>
|
||||||
<caption>Play Murder Mystery 2 on Linux</caption>
|
<caption>Play Tower of Hell on Linux</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-lobby.png</image>
|
||||||
|
<caption>Play DOORS on Linux</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<launchable type="desktop-id">io.github.CoderDayton.nightcap.desktop</launchable>
|
<launchable type="desktop-id">io.github.CoderDayton.nightcap.desktop</launchable>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM registry.fedoraproject.org/fedora:44
|
||||||
|
|
||||||
|
RUN dnf -y install \
|
||||||
|
gcc-c++ cmake ninja-build git pkgconf-pkg-config lld binutils python3 \
|
||||||
|
glslang spirv-tools \
|
||||||
|
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel json-devel \
|
||||||
|
libyaml-devel libpng-devel elfutils-libelf-devel minizip-ng-compat-devel \
|
||||||
|
capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \
|
||||||
|
utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \
|
||||||
|
vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel \
|
||||||
|
mesa-dri-drivers mesa-vulkan-drivers mesa-libEGL mesa-libGL mesa-libgbm \
|
||||||
|
pipewire-libs pulseaudio-libs alsa-lib dbus-libs libnotify \
|
||||||
|
&& dnf clean all
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
COPY . /work
|
||||||
|
RUN bash /work/build-frame-nightcap.sh
|
||||||
|
|
||||||
|
ENV PATH="/opt/nightcap-vr-arm64/bin:${PATH}"
|
||||||
|
ENTRYPOINT ["/opt/nightcap-vr-arm64/lib64/mocktail/mocktail"]
|
||||||
|
CMD ["--vr"]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run without host mounts, a GPU, SteamVR, account data or network access.
|
||||||
|
image=${1:-localhost/nightcap-vr-arm64:frame-foveated}
|
||||||
|
platform=$(podman image inspect --format '{{.Os}}/{{.Architecture}}' "$image")
|
||||||
|
if [[ "$platform" != linux/arm64 ]]; then
|
||||||
|
printf 'Expected linux/arm64 image, got %s\n' "$platform" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
podman run --rm -i --network none --read-only --cap-drop all \
|
||||||
|
--security-opt no-new-privileges --tmpfs /tmp:rw,nosuid,nodev,size=16m \
|
||||||
|
--entrypoint python3 "$image" - <<'PY'
|
||||||
|
import fnmatch
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
prefix = Path('/opt/nightcap-vr-arm64')
|
||||||
|
runtime = prefix / 'lib64/mocktail'
|
||||||
|
programs = [
|
||||||
|
prefix / 'bin/mocktail',
|
||||||
|
prefix / 'bin/mocktail-vr-probe',
|
||||||
|
prefix / 'bin/nightcap-vr-settings',
|
||||||
|
prefix / 'bin/nightcap-vr-performance',
|
||||||
|
runtime / 'mocktail',
|
||||||
|
runtime / 'mocktail_updater',
|
||||||
|
runtime / 'mocktail_failure_dialog',
|
||||||
|
runtime / 'mocktail_webview_helper',
|
||||||
|
]
|
||||||
|
libraries = list(runtime.glob('*.so'))
|
||||||
|
if len(libraries) < 12:
|
||||||
|
raise SystemExit('Installed Android adapter libraries are missing')
|
||||||
|
for path in programs + libraries:
|
||||||
|
if not path.is_file() or not os.access(path, os.X_OK):
|
||||||
|
raise SystemExit(f'Missing executable: {path}')
|
||||||
|
with path.open('rb') as stream:
|
||||||
|
header = stream.read(20)
|
||||||
|
if (header[:6] != b'\x7fELF\x02\x01' or len(header) != 20
|
||||||
|
or struct.unpack_from('<H', header, 18)[0] != 183):
|
||||||
|
raise SystemExit(f'Expected a little-endian AArch64 ELF: {path}')
|
||||||
|
result = subprocess.run(['ldd', str(path)], capture_output=True, text=True,
|
||||||
|
timeout=20, check=False)
|
||||||
|
if result.returncode or 'not found' in result.stdout + result.stderr:
|
||||||
|
raise SystemExit(f'Unresolved runtime dependencies: {path}\n'
|
||||||
|
+ result.stdout + result.stderr)
|
||||||
|
|
||||||
|
metadata = prefix / 'share/mocktail/metadata'
|
||||||
|
for name in ('roblox_compatibility.json', 'roblox_bootstrap_sources.json',
|
||||||
|
'roblox_host_abi_reference.json', 'roblox_signing_certificates.json'):
|
||||||
|
value = json.loads((metadata / name).read_text())
|
||||||
|
if not isinstance(value, (dict, list)) or not value:
|
||||||
|
raise SystemExit(f'Invalid installed metadata: {name}')
|
||||||
|
license_files = [Path('/work/LICENSE'), Path('/work/THIRD_PARTY.md')]
|
||||||
|
for path in license_files:
|
||||||
|
if not path.is_file() or not path.stat().st_size:
|
||||||
|
raise SystemExit(f'Missing source license notices: {path}')
|
||||||
|
|
||||||
|
def verify_native_cmake_settings(cache_text):
|
||||||
|
settings = {}
|
||||||
|
for line in cache_text.splitlines():
|
||||||
|
if not line or line.startswith(('#', '//')):
|
||||||
|
continue
|
||||||
|
declaration, separator, value = line.partition('=')
|
||||||
|
name, type_separator, cache_type = declaration.partition(':')
|
||||||
|
if separator and type_separator and cache_type:
|
||||||
|
settings[name] = value
|
||||||
|
# VR is a STRING option (AUTO/ON/OFF), while the other flags are BOOL.
|
||||||
|
# The configured value determines the build policy, not the cache type.
|
||||||
|
required = {'MOCKTAIL_ENABLE_VR': 'ON',
|
||||||
|
'MOCKTAIL_ENABLE_FSR3': 'OFF',
|
||||||
|
'MOCKTAIL_VR_DIAGNOSTICS': 'OFF'}
|
||||||
|
for name, expected in required.items():
|
||||||
|
if settings.get(name) != expected:
|
||||||
|
raise SystemExit(f'Unexpected build setting: {name}={settings.get(name)!r}; expected {expected}')
|
||||||
|
|
||||||
|
|
||||||
|
cache = Path('/work/build-frame-arm64/CMakeCache.txt')
|
||||||
|
build_policy = 'source-build-cache'
|
||||||
|
if cache.exists():
|
||||||
|
verify_native_cmake_settings(cache.read_text())
|
||||||
|
else:
|
||||||
|
# Source-audited release images may omit their disposable compiler cache.
|
||||||
|
provenance = json.loads((metadata / 'steam-frame-source.json').read_text())
|
||||||
|
if provenance.get('fsr3_compiled') is not False or provenance.get('upscaling') != 'Native':
|
||||||
|
raise SystemExit('Missing Native-only build provenance')
|
||||||
|
build_policy = 'installed-source-provenance'
|
||||||
|
|
||||||
|
for path in ('/opt/steamvr', '/opt/host-lib', '/work/data', '/work/runtime',
|
||||||
|
'/work/rbx_bin', '/root/.config/mocktail',
|
||||||
|
'/root/.local/share/mocktail'):
|
||||||
|
if Path(path).exists():
|
||||||
|
raise SystemExit(f'Host runtime or user data must not be packaged: {path}')
|
||||||
|
patterns = ('libroblox.so', '*.apk', '*.xapk', '*.aab', '*.obb',
|
||||||
|
'cookies*.txt', 'steamclient.so', 'vrclient.so')
|
||||||
|
# This upstream archive-parser regression fixture is not a Roblox client.
|
||||||
|
archive_fixture = Path('/work/third_party/mcpelauncher-linker/core/libziparchive/testdata/crash.apk')
|
||||||
|
for root in ('/work', '/opt', '/root', '/home'):
|
||||||
|
for directory, subdirectories, filenames in os.walk(root, followlinks=False):
|
||||||
|
if '.git' in subdirectories or '.git' in filenames:
|
||||||
|
raise SystemExit('Git checkout credentials/history must not enter the image')
|
||||||
|
for name in filenames:
|
||||||
|
path = Path(directory) / name
|
||||||
|
if path == archive_fixture:
|
||||||
|
continue
|
||||||
|
if any(fnmatch.fnmatch(name.lower(), pattern) for pattern in patterns):
|
||||||
|
raise SystemExit(f'Proprietary payload or account data in image: {path}')
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
'platform': 'linux/arm64',
|
||||||
|
'native_only_build_policy': build_policy,
|
||||||
|
'aarch64_programs_checked': len(programs),
|
||||||
|
'aarch64_adapter_libraries_checked': len(libraries),
|
||||||
|
'dynamic_dependencies': 'resolved',
|
||||||
|
'client_payload': 'absent',
|
||||||
|
'host_steamvr_and_driver_mounts': 'absent',
|
||||||
|
'license_sha256': {str(path): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
for path in license_files},
|
||||||
|
'scope': 'offline packaging checks; no headset or Roblox session started',
|
||||||
|
}, indent=2))
|
||||||
|
PY
|
||||||
@@ -62,7 +62,7 @@ Branch=stable
|
|||||||
Url=${BASE_URL}/repo/
|
Url=${BASE_URL}/repo/
|
||||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
Homepage=https://github.com/CoderDayton/nightcap
|
Homepage=https://github.com/CoderDayton/nightcap
|
||||||
Comment=Roblox on Linux, tuned for desktop play
|
Comment=Roblox on Linux, tuned for a real PC
|
||||||
Icon=${BASE_URL}/nightcap.svg
|
Icon=${BASE_URL}/nightcap.svg
|
||||||
GPGKey=${GPG_KEY}
|
GPGKey=${GPG_KEY}
|
||||||
IsRuntime=false
|
IsRuntime=false
|
||||||
@@ -72,20 +72,3 @@ install -m 0644 -- "${PROJECT_DIR}/site/index.html" "${OUTPUT}/index.html"
|
|||||||
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
|
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
|
||||||
install -m 0644 -- "${PROJECT_DIR}/site/terms.html" "${OUTPUT}/terms.html"
|
install -m 0644 -- "${PROJECT_DIR}/site/terms.html" "${OUTPUT}/terms.html"
|
||||||
install -m 0644 -- "${PROJECT_DIR}/site/privacy.html" "${OUTPUT}/privacy.html"
|
install -m 0644 -- "${PROJECT_DIR}/site/privacy.html" "${OUTPUT}/privacy.html"
|
||||||
install -m 0644 -- "${PROJECT_DIR}/site/preview.png" "${OUTPUT}/preview.png"
|
|
||||||
|
|
||||||
cat >"${OUTPUT}/robots.txt" <<EOF
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: ${BASE_URL}/sitemap.xml
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat >"${OUTPUT}/sitemap.xml" <<EOF
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url><loc>${BASE_URL}/</loc></url>
|
|
||||||
<url><loc>${BASE_URL}/terms.html</loc></url>
|
|
||||||
<url><loc>${BASE_URL}/privacy.html</loc></url>
|
|
||||||
</urlset>
|
|
||||||
EOF
|
|
||||||
Executable
+1609
File diff suppressed because it is too large.
Load diff
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate src/vr/vr_shaders_spirv.h from shaders/vr with glslc.
|
||||||
|
|
||||||
|
The header is checked in so a build never needs a shader compiler. Run this
|
||||||
|
after editing a shader; VrShaders.SpirvMatchesSources checks it when glslc is
|
||||||
|
installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
SHADERS = [
|
||||||
|
("kFullscreenVertexSpirv", "shaders/vr/fullscreen.vert"),
|
||||||
|
("kFxaaFragmentSpirv", "shaders/vr/fxaa.frag"),
|
||||||
|
]
|
||||||
|
OUTPUT = ROOT / "src/vr/vr_shaders_spirv.h"
|
||||||
|
|
||||||
|
|
||||||
|
def compile_words(glslc, source):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
output = pathlib.Path(directory) / "shader.spv"
|
||||||
|
subprocess.run(
|
||||||
|
[glslc, "-O", "--target-env=vulkan1.0", str(ROOT / source), "-o",
|
||||||
|
str(output)],
|
||||||
|
check=True)
|
||||||
|
data = output.read_bytes()
|
||||||
|
return struct.unpack("<%dI" % (len(data) // 4), data)
|
||||||
|
|
||||||
|
|
||||||
|
def render(glslc):
|
||||||
|
lines = [
|
||||||
|
"// Generated by scripts/generate_vr_shaders.py from shaders/vr. Do not edit.",
|
||||||
|
"#ifndef MOCKTAIL_VR_VR_SHADERS_SPIRV_H_",
|
||||||
|
"#define MOCKTAIL_VR_VR_SHADERS_SPIRV_H_",
|
||||||
|
"",
|
||||||
|
"#include <cstdint>",
|
||||||
|
"",
|
||||||
|
"namespace mocktail::vr {",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for name, source in SHADERS:
|
||||||
|
words = compile_words(glslc, source)
|
||||||
|
lines.append("// %s" % source)
|
||||||
|
lines.append("inline constexpr std::uint32_t %s[] = {" % name)
|
||||||
|
for start in range(0, len(words), 6):
|
||||||
|
chunk = ", ".join("0x%08x" % word for word in words[start:start + 6])
|
||||||
|
lines.append(" %s," % chunk)
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("")
|
||||||
|
lines += ["} // namespace mocktail::vr", "",
|
||||||
|
"#endif // MOCKTAIL_VR_VR_SHADERS_SPIRV_H_", ""]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--glslc", default="glslc")
|
||||||
|
parser.add_argument("--check", action="store_true",
|
||||||
|
help="exit 1 when the header is out of date")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
text = render(arguments.glslc)
|
||||||
|
if arguments.check:
|
||||||
|
current = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||||
|
if current != text:
|
||||||
|
print("%s is out of date; run %s" % (OUTPUT, __file__),
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
OUTPUT.write_text(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -23,6 +23,10 @@ Usage:
|
|||||||
|
|
||||||
Prints every field where the two disagree. Exit status: 0 agree, 1 differ or
|
Prints every field where the two disagree. Exit status: 0 agree, 1 differ or
|
||||||
one side failed, 2 both failed.
|
one side failed, 2 both failed.
|
||||||
|
|
||||||
|
With --vr, the VR device bridge is compared too. `find_roblox_vr_offsets.py`
|
||||||
|
finds it in each build on its own: its answer for the reference build is the
|
||||||
|
solver's input, and its answer for the candidate joins the deriver's side.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,25 +38,73 @@ import sys
|
|||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
|
||||||
def _load_solver():
|
def _load_sibling(name: str, file_name: str):
|
||||||
name = "mocktail_roblox_abi_solver"
|
|
||||||
if name in sys.modules:
|
if name in sys.modules:
|
||||||
return sys.modules[name]
|
return sys.modules[name]
|
||||||
path = Path(__file__).resolve().with_name("roblox_abi_solver.py")
|
path = Path(__file__).resolve().with_name(file_name)
|
||||||
specification = importlib.util.spec_from_file_location(name, path)
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
if specification is None or specification.loader is None:
|
if specification is None or specification.loader is None:
|
||||||
raise RuntimeError("cannot load roblox_abi_solver.py")
|
raise RuntimeError(f"cannot load {file_name}")
|
||||||
module = importlib.util.module_from_spec(specification)
|
module = importlib.util.module_from_spec(specification)
|
||||||
sys.modules[name] = module
|
sys.modules[name] = module
|
||||||
specification.loader.exec_module(module)
|
specification.loader.exec_module(module)
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
SOLVER = _load_solver()
|
SOLVER = _load_sibling("mocktail_roblox_abi_solver", "roblox_abi_solver.py")
|
||||||
|
FINDER = _load_sibling("mocktail_find_roblox_vr_offsets", "find_roblox_vr_offsets.py")
|
||||||
DERIVER = SOLVER.DERIVER
|
DERIVER = SOLVER.DERIVER
|
||||||
AnalyzerError = SOLVER.AnalyzerError
|
AnalyzerError = SOLVER.AnalyzerError
|
||||||
format_rva = SOLVER.format_rva
|
format_rva = SOLVER.format_rva
|
||||||
|
|
||||||
|
# Where find_roblox_vr_offsets.py reports each VR bridge field.
|
||||||
|
_FINDER_FIELDS = {
|
||||||
|
"vtable_rva": ("vr_debug_device_bridge", "vtable_rva"),
|
||||||
|
"constructor_rva": ("vr_debug_device_bridge", "constructor_rva"),
|
||||||
|
"state_getter_rva": ("vr_debug_device_bridge", "state_getter_rva"),
|
||||||
|
"eye_getter_rva": ("vr_debug_device_bridge", "eye_getter_rva"),
|
||||||
|
"eye_initializer_rva": ("vr_debug_device_bridge", "eye_initializer_rva"),
|
||||||
|
"emulator_flag_storage_rva": ("vr_debug_device_bridge", "emulator_flag_storage_rva"),
|
||||||
|
"device_create_framebuffer_vtable_offset": (
|
||||||
|
"vr_debug_device_bridge", "device_create_framebuffer_vtable_offset"
|
||||||
|
),
|
||||||
|
"state_memcpy_rva": ("vr_bridge_supplement", "state_getter_memcpy_rva"),
|
||||||
|
"pointer_frame_rva": ("vr_bridge_supplement", "world_pointer_getter_rva"),
|
||||||
|
"workspace_getter_rva": ("vr_bridge_supplement", "workspace_getter_rva"),
|
||||||
|
"camera_vtable_offset": ("vr_bridge_supplement", "workspace_camera_vtable_offset"),
|
||||||
|
"hand_pitch_storage_rva": ("vr_bridge_supplement", "new_vr_system_hand_pitch_storage_rva"),
|
||||||
|
"haptic_sink_rva": ("vr_bridge_supplement", "haptic_sink_rva"),
|
||||||
|
"pointer_hand_selector_offset": ("vr_bridge_supplement", "usercframe_selector_offset"),
|
||||||
|
"camera_head_scale_offset": ("vr_bridge_supplement", "camera_head_scale_offset"),
|
||||||
|
"keep_haptics_flag_rva": ("vr_bridge_supplement", "keep_haptics_flag_rva"),
|
||||||
|
"vr_haptics_flag_rva": ("vr_bridge_supplement", "vr_haptics_flag_rva"),
|
||||||
|
"support_generic_haptics_flag_rva": (
|
||||||
|
"vr_bridge_supplement", "support_generic_haptics_flag_rva"
|
||||||
|
),
|
||||||
|
"fix_register_motors_flag_rva": ("vr_bridge_supplement", "fix_register_motors_flag_rva"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vr_bridge_from_finder(
|
||||||
|
result: dict[str, Any], fields: Sequence[str] = SOLVER.VR.VR_FIELDS
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""The VR bridge fields from a find_roblox_vr_offsets.py result."""
|
||||||
|
bridge = {}
|
||||||
|
for field in fields:
|
||||||
|
section, key = _FINDER_FIELDS[field]
|
||||||
|
try:
|
||||||
|
bridge[field] = result[section][key]
|
||||||
|
except (KeyError, TypeError) as error:
|
||||||
|
raise AnalyzerError(f"VR offset finder result lacks {key}") from error
|
||||||
|
return bridge
|
||||||
|
|
||||||
|
|
||||||
|
def _find_vr_bridge(library: Path) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
return vr_bridge_from_finder(FINDER.find_vr_offsets(library))
|
||||||
|
except FINDER.AnalyzerError as error:
|
||||||
|
raise AnalyzerError(f"VR offset finder: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
def compare_results(derived: Any, solved: Any, path: str = "") -> list[str]:
|
def compare_results(derived: Any, solved: Any, path: str = "") -> list[str]:
|
||||||
"""Returns one line for every leaf value where the two results differ."""
|
"""Returns one line for every leaf value where the two results differ."""
|
||||||
@@ -121,6 +173,7 @@ def check(arguments: argparse.Namespace) -> int:
|
|||||||
manifests = [Path(path) for path in arguments.reference_compatibility]
|
manifests = [Path(path) for path in arguments.reference_compatibility]
|
||||||
cache_dir = Path(arguments.cache_dir) if arguments.cache_dir else None
|
cache_dir = Path(arguments.cache_dir) if arguments.cache_dir else None
|
||||||
derived = solved = deriver_error = solver_error = None
|
derived = solved = deriver_error = solver_error = None
|
||||||
|
reference_vr = _find_vr_bridge(Path(arguments.reference_lib)) if arguments.vr else None
|
||||||
with DERIVER.ElfImage(Path(arguments.reference_lib)) as reference, DERIVER.ElfImage(
|
with DERIVER.ElfImage(Path(arguments.reference_lib)) as reference, DERIVER.ElfImage(
|
||||||
Path(arguments.candidate_lib)
|
Path(arguments.candidate_lib)
|
||||||
) as candidate:
|
) as candidate:
|
||||||
@@ -128,6 +181,8 @@ def check(arguments: argparse.Namespace) -> int:
|
|||||||
profile, anchors = DERIVER.derive_profile(reference, candidate, sidecar)
|
profile, anchors = DERIVER.derive_profile(reference, candidate, sidecar)
|
||||||
runtime = DERIVER.derive_runtime_compatibility(reference, candidate, manifests)
|
runtime = DERIVER.derive_runtime_compatibility(reference, candidate, manifests)
|
||||||
derived = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
derived = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
||||||
|
if reference_vr is not None:
|
||||||
|
derived["vr_device_bridge"] = _find_vr_bridge(Path(arguments.candidate_lib))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
deriver_error = _describe(error)
|
deriver_error = _describe(error)
|
||||||
try:
|
try:
|
||||||
@@ -138,6 +193,8 @@ def check(arguments: argparse.Namespace) -> int:
|
|||||||
source = DERIVER.load_reference_runtime_profile(manifests, reference.build_id)
|
source = DERIVER.load_reference_runtime_profile(manifests, reference.build_id)
|
||||||
runtime = solver.derive_runtime_compatibility(_solver_runtime_source(source))
|
runtime = solver.derive_runtime_compatibility(_solver_runtime_source(source))
|
||||||
solved = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
solved = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
||||||
|
if reference_vr is not None:
|
||||||
|
solved["vr_device_bridge"] = solver.derive_vr(reference_vr)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
solver_error = _describe(error)
|
solver_error = _describe(error)
|
||||||
status, lines = check_status(derived, solved, deriver_error, solver_error)
|
status, lines = check_status(derived, solved, deriver_error, solver_error)
|
||||||
@@ -161,6 +218,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
default=str(Path.home() / ".cache" / "mocktail" / "function_index"),
|
default=str(Path.home() / ".cache" / "mocktail" / "function_index"),
|
||||||
help="where function indexes are kept; empty to disable",
|
help="where function indexes are kept; empty to disable",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--vr", action="store_true", help="also compare the VR device bridge"
|
||||||
|
)
|
||||||
arguments = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
arguments = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
try:
|
try:
|
||||||
return check(arguments)
|
return check(arguments)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ reference function is matched through independent clues:
|
|||||||
A match is accepted only with a score of at least ACCEPT_SCORE and a lead of
|
A match is accepted only with a score of at least ACCEPT_SCORE and a lead of
|
||||||
at least ACCEPT_LEAD over the runner-up. `roblox_abi_targets.py` turns the
|
at least ACCEPT_LEAD over the runner-up. `roblox_abi_targets.py` turns the
|
||||||
matches into a profile with the same shape as the one
|
matches into a profile with the same shape as the one
|
||||||
`derive_roblox_host_abi_profile.py` writes, and `roblox_abi_check.py`
|
`derive_roblox_host_abi_profile.py` writes, `roblox_abi_vr.py` into the VR
|
||||||
compares the two on one update.
|
device bridge, and `roblox_abi_check.py` compares the two on one update.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -58,6 +58,7 @@ def _load_sibling(name: str, file_name: str):
|
|||||||
|
|
||||||
INDEX = _load_sibling("mocktail_roblox_function_index", "roblox_function_index.py")
|
INDEX = _load_sibling("mocktail_roblox_function_index", "roblox_function_index.py")
|
||||||
TARGETS = _load_sibling("mocktail_roblox_abi_targets", "roblox_abi_targets.py")
|
TARGETS = _load_sibling("mocktail_roblox_abi_targets", "roblox_abi_targets.py")
|
||||||
|
VR = _load_sibling("mocktail_roblox_abi_vr", "roblox_abi_vr.py")
|
||||||
DERIVER = INDEX.DERIVER
|
DERIVER = INDEX.DERIVER
|
||||||
FunctionIndex = INDEX.FunctionIndex
|
FunctionIndex = INDEX.FunctionIndex
|
||||||
Instruction = INDEX.Instruction
|
Instruction = INDEX.Instruction
|
||||||
@@ -136,7 +137,7 @@ def _weighted(parts: Sequence[tuple[float | None, float]]) -> float:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Solver(TARGETS.TargetDerivation):
|
class Solver(TARGETS.TargetDerivation, VR.VrTargets):
|
||||||
def __init__(self, reference: Any, candidate: Any, cache_dir: Path | None = None):
|
def __init__(self, reference: Any, candidate: Any, cache_dir: Path | None = None):
|
||||||
self._start(FunctionIndex(reference, cache_dir), FunctionIndex(candidate, cache_dir))
|
self._start(FunctionIndex(reference, cache_dir), FunctionIndex(candidate, cache_dir))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2026 Mocktail Project Authors
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""VR device bridge targets that `roblox_abi_solver.py` derives.
|
||||||
|
|
||||||
|
`VrTargets.derive_vr` takes one build's VR bridge (the `VrBuildContract`
|
||||||
|
fields in `src/vr/roblox_vr_device_bridge.cc`, as hex strings) and returns
|
||||||
|
the same fields for the candidate build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _load_index():
|
||||||
|
name = "mocktail_roblox_function_index"
|
||||||
|
if name in sys.modules:
|
||||||
|
return sys.modules[name]
|
||||||
|
path = Path(__file__).resolve().with_name("roblox_function_index.py")
|
||||||
|
specification = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise RuntimeError("cannot load roblox_function_index.py")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[name] = module
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DERIVER = _load_index().DERIVER
|
||||||
|
AnalyzerError = DERIVER.AnalyzerError
|
||||||
|
format_rva = DERIVER.format_rva
|
||||||
|
|
||||||
|
VR_FIELDS = (
|
||||||
|
"vtable_rva",
|
||||||
|
"constructor_rva",
|
||||||
|
"state_getter_rva",
|
||||||
|
"eye_getter_rva",
|
||||||
|
"eye_initializer_rva",
|
||||||
|
"emulator_flag_storage_rva",
|
||||||
|
"device_create_framebuffer_vtable_offset",
|
||||||
|
"state_memcpy_rva",
|
||||||
|
"pointer_frame_rva",
|
||||||
|
"workspace_getter_rva",
|
||||||
|
"camera_vtable_offset",
|
||||||
|
"hand_pitch_storage_rva",
|
||||||
|
"haptic_sink_rva",
|
||||||
|
"pointer_hand_selector_offset",
|
||||||
|
"camera_head_scale_offset",
|
||||||
|
"keep_haptics_flag_rva",
|
||||||
|
"vr_haptics_flag_rva",
|
||||||
|
"support_generic_haptics_flag_rva",
|
||||||
|
"fix_register_motors_flag_rva",
|
||||||
|
)
|
||||||
|
# Fast flag and setting storage, found through the functions that use it.
|
||||||
|
_STORAGE_FIELDS = (
|
||||||
|
"emulator_flag_storage_rva",
|
||||||
|
"hand_pitch_storage_rva",
|
||||||
|
"keep_haptics_flag_rva",
|
||||||
|
"vr_haptics_flag_rva",
|
||||||
|
"support_generic_haptics_flag_rva",
|
||||||
|
"fix_register_motors_flag_rva",
|
||||||
|
)
|
||||||
|
# Storage users tried, smallest first; registration functions are small.
|
||||||
|
_STORAGE_USER_LIMIT = 8
|
||||||
|
_VTABLE_SLOT_LIMIT = 0x400
|
||||||
|
# The constructor stores the vtable address plus the Itanium header.
|
||||||
|
_VTABLE_REFERENCE_SPAN = 0x40
|
||||||
|
_NUMBER = r"(0x[0-9a-f]+|\d+)"
|
||||||
|
_SLOT_LOAD = re.compile(r"^mov (\w+), qword ptr \[\1 \+ ")
|
||||||
|
# How far back the load of the vtable pointer itself may sit.
|
||||||
|
_VTABLE_LOAD_WINDOW = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _vtable_call(instructions: list[Any], index: int) -> bool:
|
||||||
|
"""A call through a vtable slot, or a load of the slot from a vtable
|
||||||
|
pointer loaded just before into the same register."""
|
||||||
|
shape = instructions[index].shape
|
||||||
|
if shape.startswith("call qword ptr ["):
|
||||||
|
return True
|
||||||
|
load = _SLOT_LOAD.match(shape)
|
||||||
|
if load is None:
|
||||||
|
return False
|
||||||
|
vtable_load = re.compile(rf"^mov {load.group(1)}, qword ptr \[\w+\]$")
|
||||||
|
return any(
|
||||||
|
vtable_load.match(previous.shape)
|
||||||
|
for previous in instructions[max(0, index - _VTABLE_LOAD_WINDOW) : index]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VrTargets:
|
||||||
|
"""Mixin for `Solver`: needs `reference`, `candidate`, `matches`,
|
||||||
|
`match_function`, `map_data` and `_aligned`."""
|
||||||
|
|
||||||
|
def map_operand(
|
||||||
|
self, name: str, function_name: str, start: int, value: int, vtable_call: bool = False
|
||||||
|
) -> int:
|
||||||
|
"""Maps a field offset that a matched function uses as a displacement.
|
||||||
|
|
||||||
|
Stack-relative uses are ignored; with `vtable_call`, so is every use
|
||||||
|
that is not a virtual call. An instruction whose shape changed is
|
||||||
|
looked for between its nearest aligned neighbours.
|
||||||
|
"""
|
||||||
|
reference, candidate, mapping = self._aligned(function_name, start)
|
||||||
|
token = re.compile(rf"(?<![0-9a-fx]){re.escape(hex(value))}(?![0-9a-f])")
|
||||||
|
found = set()
|
||||||
|
for index, item in enumerate(reference):
|
||||||
|
if not token.search(item.shape) or "rsp" in item.shape or "rbp" in item.shape:
|
||||||
|
continue
|
||||||
|
if vtable_call and not _vtable_call(reference, index):
|
||||||
|
continue
|
||||||
|
pattern = re.compile(
|
||||||
|
"^" + token.sub(lambda _: _NUMBER, re.escape(item.shape)) + "$"
|
||||||
|
)
|
||||||
|
if index in mapping:
|
||||||
|
window = range(mapping[index], mapping[index] + 1)
|
||||||
|
else:
|
||||||
|
below = max((k for k in mapping if k < index), default=None)
|
||||||
|
above = min((k for k in mapping if k > index), default=None)
|
||||||
|
low = 0 if below is None else mapping[below] + 1
|
||||||
|
high = len(candidate) if above is None else mapping[above]
|
||||||
|
window = range(low, high)
|
||||||
|
values = []
|
||||||
|
for position in window:
|
||||||
|
if vtable_call and not _vtable_call(candidate, position):
|
||||||
|
continue
|
||||||
|
matched = pattern.match(candidate[position].shape)
|
||||||
|
if matched is not None and len({*matched.groups()}) == 1:
|
||||||
|
values.append(int(matched.group(1), 0))
|
||||||
|
if len(values) != 1:
|
||||||
|
raise AnalyzerError(f"{name} has {len(values)} candidate offsets")
|
||||||
|
found.add(values[0])
|
||||||
|
if len(found) != 1:
|
||||||
|
raise AnalyzerError(f"{name} maps to {len(found)} offsets")
|
||||||
|
return found.pop()
|
||||||
|
|
||||||
|
def map_call_target(self, name: str, function_name: str, start: int, target: int) -> int:
|
||||||
|
"""Maps the target of a direct call inside a matched function."""
|
||||||
|
reference, candidate, mapping = self._aligned(function_name, start)
|
||||||
|
found = set()
|
||||||
|
for index, item in enumerate(reference):
|
||||||
|
if item.shape != "call" or item.branch_target != target:
|
||||||
|
continue
|
||||||
|
if index not in mapping:
|
||||||
|
raise AnalyzerError(f"{name} call has no counterpart")
|
||||||
|
found.add(candidate[mapping[index]].branch_target)
|
||||||
|
if len(found) != 1 or None in found:
|
||||||
|
raise AnalyzerError(f"{name} has no unique call target")
|
||||||
|
return found.pop()
|
||||||
|
|
||||||
|
def _map_storage(self, name: str, target: int) -> int:
|
||||||
|
users = sorted(
|
||||||
|
self.reference.data_users.get(target, ()),
|
||||||
|
key=lambda start: self.reference.functions[start].size,
|
||||||
|
)[:_STORAGE_USER_LIMIT]
|
||||||
|
found = set()
|
||||||
|
for user in users:
|
||||||
|
user_name = f"{name} user {format_rva(user)}"
|
||||||
|
try:
|
||||||
|
self.match_function(user_name, user)
|
||||||
|
found.add(self.map_data(name, user_name, user, target))
|
||||||
|
except AnalyzerError:
|
||||||
|
continue
|
||||||
|
if len(found) != 1:
|
||||||
|
raise AnalyzerError(f"{name} maps to {len(found)} addresses")
|
||||||
|
return found.pop()
|
||||||
|
|
||||||
|
def _map_vtable(self, constructor: int, vtable: int) -> int:
|
||||||
|
found = set()
|
||||||
|
for item in self.reference.instructions(constructor):
|
||||||
|
for target in item.rip_targets:
|
||||||
|
if 0 <= target - vtable < _VTABLE_REFERENCE_SPAN:
|
||||||
|
mapped = self.map_data("VR vtable", "vr-constructor", constructor, target)
|
||||||
|
found.add(mapped - (target - vtable))
|
||||||
|
if len(found) != 1:
|
||||||
|
raise AnalyzerError(f"VR vtable maps to {len(found)} addresses")
|
||||||
|
return found.pop()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slot(relocations: dict[int, int], vtable: int, method: int) -> int:
|
||||||
|
slots = [
|
||||||
|
offset
|
||||||
|
for offset in range(0, _VTABLE_SLOT_LIMIT, 8)
|
||||||
|
if relocations.get(vtable + offset) == method
|
||||||
|
]
|
||||||
|
if not slots:
|
||||||
|
raise AnalyzerError(f"reference VR vtable does not hold {format_rva(method)}")
|
||||||
|
return slots[0]
|
||||||
|
|
||||||
|
def derive_vr(self, source: dict[str, str]) -> dict[str, str]:
|
||||||
|
missing = [field for field in VR_FIELDS if field not in source]
|
||||||
|
if missing:
|
||||||
|
raise AnalyzerError("reference VR bridge lacks " + ", ".join(missing))
|
||||||
|
value = {field: DERIVER.parse_rva(source[field], field) for field in VR_FIELDS}
|
||||||
|
result = {}
|
||||||
|
constructor = self.match_function("vr-constructor", value["constructor_rva"])
|
||||||
|
result["constructor_rva"] = constructor
|
||||||
|
vtable = self._map_vtable(value["constructor_rva"], value["vtable_rva"])
|
||||||
|
result["vtable_rva"] = vtable
|
||||||
|
reference_relocations = DERIVER.relative_relocation_map(self.reference.image)
|
||||||
|
relocations = DERIVER.relative_relocation_map(self.candidate.image)
|
||||||
|
slots = {
|
||||||
|
field: self._slot(reference_relocations, value["vtable_rva"], value[field])
|
||||||
|
for field in (
|
||||||
|
"state_getter_rva", "eye_getter_rva", "eye_initializer_rva", "haptic_sink_rva"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.candidate.image.require_relro_rva(vtable, max(slots.values()) + 8)
|
||||||
|
for field in ("state_getter_rva", "eye_getter_rva", "eye_initializer_rva"):
|
||||||
|
result[field] = self.match_function(
|
||||||
|
f"vr-{field}", value[field], vtable_slot=relocations.get(vtable + slots[field])
|
||||||
|
)
|
||||||
|
# The haptic sink is a one-byte ret stub, so only its slot identifies it.
|
||||||
|
sink = relocations.get(vtable + slots["haptic_sink_rva"])
|
||||||
|
if sink is None or sink not in self.candidate.functions or (
|
||||||
|
self.candidate.functions[sink].size != 1
|
||||||
|
):
|
||||||
|
raise AnalyzerError("VR haptic sink slot does not hold a ret stub")
|
||||||
|
result["haptic_sink_rva"] = sink
|
||||||
|
result["state_memcpy_rva"] = self.map_call_target(
|
||||||
|
"VR state memcpy", "vr-state_getter_rva",
|
||||||
|
value["state_getter_rva"], value["state_memcpy_rva"],
|
||||||
|
)
|
||||||
|
result["device_create_framebuffer_vtable_offset"] = self.map_operand(
|
||||||
|
"VR framebuffer slot", "vr-eye_initializer_rva",
|
||||||
|
value["eye_initializer_rva"], value["device_create_framebuffer_vtable_offset"],
|
||||||
|
vtable_call=True,
|
||||||
|
)
|
||||||
|
result["pointer_frame_rva"] = self.match_function(
|
||||||
|
"vr-pointer-frame", value["pointer_frame_rva"]
|
||||||
|
)
|
||||||
|
result["workspace_getter_rva"] = self.map_call_target(
|
||||||
|
"VR workspace getter", "vr-pointer-frame",
|
||||||
|
value["pointer_frame_rva"], value["workspace_getter_rva"],
|
||||||
|
)
|
||||||
|
for field in (
|
||||||
|
"camera_vtable_offset", "pointer_hand_selector_offset", "camera_head_scale_offset"
|
||||||
|
):
|
||||||
|
result[field] = self.map_operand(
|
||||||
|
field, "vr-pointer-frame", value["pointer_frame_rva"], value[field],
|
||||||
|
vtable_call=field == "camera_vtable_offset",
|
||||||
|
)
|
||||||
|
for field in _STORAGE_FIELDS:
|
||||||
|
result[field] = self._map_storage(field, value[field])
|
||||||
|
self.candidate.image.require_writable_rva(result[field], 1)
|
||||||
|
return {field: format_rva(result[field]) for field in VR_FIELDS}
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
Usage: summarize_profile_trace.py TRACE [TRACE ...]
|
Usage: summarize_profile_trace.py TRACE [TRACE ...]
|
||||||
|
|
||||||
Each trace gets its own column, so `before.json after.json` compares them.
|
Each trace gets its own column, so `before.json after.json` compares them.
|
||||||
Frame times come from the "frame interval (ms)" counter; every other row is
|
Frame times come from the "frame interval (ms)" counter, peaks from the
|
||||||
a slice name recorded by the Vulkan adapter.
|
texture counters; every other row is a slice name recorded by the Vulkan
|
||||||
|
adapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,6 +21,7 @@ import sys
|
|||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
FRAME_COUNTER = "frame interval (ms)"
|
FRAME_COUNTER = "frame interval (ms)"
|
||||||
|
PEAK_COUNTERS = ("emulated texture (MB)", "compressed texture (MB)")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -37,6 +39,7 @@ class Distribution:
|
|||||||
class Summary:
|
class Summary:
|
||||||
frames: Distribution | None
|
frames: Distribution | None
|
||||||
slices: dict[str, Distribution]
|
slices: dict[str, Distribution]
|
||||||
|
peaks: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
def _nearest_rank(ordered: list[float], percent: float) -> float:
|
def _nearest_rank(ordered: list[float], percent: float) -> float:
|
||||||
@@ -57,16 +60,21 @@ def _distribution(values: list[float]) -> Distribution:
|
|||||||
def summarize(events: Iterable[dict]) -> Summary:
|
def summarize(events: Iterable[dict]) -> Summary:
|
||||||
frames: list[float] = []
|
frames: list[float] = []
|
||||||
slices: dict[str, list[float]] = {}
|
slices: dict[str, list[float]] = {}
|
||||||
|
peaks: dict[str, float] = {}
|
||||||
for event in events:
|
for event in events:
|
||||||
phase = event.get("ph")
|
phase = event.get("ph")
|
||||||
if phase == "C" and event.get("name") == FRAME_COUNTER:
|
if phase == "C" and event.get("name") == FRAME_COUNTER:
|
||||||
frames.append(float(event["args"]["value"]))
|
frames.append(float(event["args"]["value"]))
|
||||||
|
elif phase == "C" and event.get("name") in PEAK_COUNTERS:
|
||||||
|
value = float(event["args"]["value"])
|
||||||
|
peaks[event["name"]] = max(peaks.get(event["name"], value), value)
|
||||||
elif phase == "X":
|
elif phase == "X":
|
||||||
slices.setdefault(event["name"], []).append(event["dur"] / 1000)
|
slices.setdefault(event["name"], []).append(event["dur"] / 1000)
|
||||||
return Summary(
|
return Summary(
|
||||||
frames=_distribution(frames) if frames else None,
|
frames=_distribution(frames) if frames else None,
|
||||||
slices={name: _distribution(values)
|
slices={name: _distribution(values)
|
||||||
for name, values in slices.items()})
|
for name, values in slices.items()},
|
||||||
|
peaks=peaks)
|
||||||
|
|
||||||
|
|
||||||
def _row(label: str, cells: list[str]) -> str:
|
def _row(label: str, cells: list[str]) -> str:
|
||||||
@@ -96,6 +104,11 @@ def to_markdown(traces: list[tuple[str, Summary]]) -> str:
|
|||||||
if any(frames):
|
if any(frames):
|
||||||
lines += [_metric_row(f"frame interval {field} (ms)", frames, field)
|
lines += [_metric_row(f"frame interval {field} (ms)", frames, field)
|
||||||
for field in ("mean", "p50", "p95", "p99", "max")]
|
for field in ("mean", "p50", "p95", "p99", "max")]
|
||||||
|
for name in PEAK_COUNTERS:
|
||||||
|
if any(name in summary.peaks for summary in summaries):
|
||||||
|
lines.append(_row(f"{name} peak", [
|
||||||
|
f"{summary.peaks[name]:.2f}" if name in summary.peaks else "-"
|
||||||
|
for summary in summaries]))
|
||||||
|
|
||||||
# Largest total time first, taken from whichever trace spent the most.
|
# Largest total time first, taken from whichever trace spent the most.
|
||||||
totals: dict[str, float] = {}
|
totals: dict[str, float] = {}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#version 450
|
||||||
|
// One triangle that covers the viewport; uv spans 0..1 over the target.
|
||||||
|
layout(location = 0) out vec2 uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||||
|
gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user