mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 23:00:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adccf4e484 | ||
|
|
c4be3de14a | ||
|
|
8370850bb7 | ||
|
|
876f8b8c32 | ||
|
|
1854e00934 | ||
|
|
c614fba032 | ||
|
|
0221738952 | ||
|
|
046632cf99 | ||
|
|
753a3f8106 | ||
|
|
569df68729 | ||
|
|
043964a5dc | ||
|
|
ea248b1ecf | ||
|
|
35033454b0 | ||
|
|
df025d2eb7 | ||
|
|
dea1d5fd59 | ||
|
|
9ae5667cd7 | ||
|
|
7296520064 | ||
|
|
a3a2b76a06 | ||
|
|
f333399e18 | ||
|
|
e094a4c89d | ||
|
|
de0ddf7864 | ||
|
|
e553db2001 | ||
|
|
44ec71f826 | ||
|
|
3062909209 | ||
|
|
049784d0f8 | ||
|
|
ccf910f064 | ||
|
|
42b5138b11 | ||
|
|
2eaba9048b | ||
|
|
a2ffa5c811 | ||
|
|
2be319a979 | ||
|
|
6cf641d472 | ||
|
|
3788026d10 | ||
|
|
f3e68fc3b7 | ||
|
|
23e7831149 | ||
|
|
5b2ee2c7f3 | ||
|
|
63ed62b4aa | ||
|
|
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 | ||
|
|
81de528668 | ||
|
|
ff81882cb0 | ||
|
|
f9fede1649 | ||
|
|
a87373d003 | ||
|
|
b46098c211 | ||
|
|
144350090e | ||
|
|
3ed0418997 | ||
|
|
8410070eec | ||
|
|
a1166fb6ba | ||
|
|
c9eb4fb7f0 | ||
|
|
b59c070cc0 | ||
|
|
4a9acf8c49 | ||
|
|
c342bab7b5 | ||
|
|
a3b9c872a5 | ||
|
|
4f45baf3f7 | ||
|
|
0ddb546ba8 | ||
|
|
7ba752ebb7 | ||
|
|
227895faa4 | ||
|
|
dfc3c8d555 | ||
|
|
e490dff97f | ||
|
|
b5b9990017 | ||
|
|
d278c0fe0a | ||
|
|
044f97e476 | ||
|
|
321ba5b3a2 | ||
|
|
fd364a7bf2 | ||
|
|
5253563e3f | ||
|
|
551d2a1829 | ||
|
|
37b88103f4 | ||
|
|
763faed0e3 | ||
|
|
74eedada0b | ||
|
|
6cb3dd00a9 | ||
|
|
fe2f641a9e | ||
|
|
cb2e9dd598 | ||
|
|
bc108106db | ||
|
|
aa31cdf395 | ||
|
|
3e6f63ea93 | ||
|
|
bfa6811417 | ||
|
|
84502c0a1a | ||
|
|
46d1d5ffbe | ||
|
|
545fd8344f | ||
|
|
4c40fcdc99 | ||
|
|
d4c7a6f741 | ||
|
|
491925f59e | ||
|
|
2917bc5423 | ||
|
|
2451264696 | ||
|
|
a9a96c6861 | ||
|
|
5bf4eeb41b | ||
|
|
d937dd7ea6 | ||
|
|
169e65bf60 | ||
|
|
69476f0f47 | ||
|
|
79d0cdfb40 | ||
|
|
273209b9ea | ||
|
|
bbfd603e17 | ||
|
|
1ba2941e7f | ||
|
|
68c90ce428 | ||
|
|
9a1ccbb5ce | ||
|
|
068f07e125 | ||
|
|
a469689bbb | ||
|
|
51836a980d | ||
|
|
ad546a7781 | ||
|
|
3c45450dfc | ||
|
|
185c11b57f | ||
|
|
2c3d7b485e | ||
|
|
6f8344a28b | ||
|
|
b60c43e8f0 | ||
|
|
b5e4273277 | ||
|
|
d2858fedc8 | ||
|
|
326a0659ea | ||
|
|
dd45467056 | ||
|
|
94b1036296 | ||
|
|
4c61daad0c | ||
|
|
f5f224875a | ||
|
|
90d4891a4e | ||
|
|
46dd99266c | ||
|
|
77fce18a95 | ||
|
|
ecb80b3ee1 | ||
|
|
d6f0f2c755 | ||
|
|
c6afc5af24 | ||
|
|
74e7455ee1 | ||
|
|
000d021452 | ||
|
|
2eef2c3821 | ||
|
|
4928443f90 | ||
|
|
b37497db7b | ||
|
|
6bbdc39d7e | ||
|
|
1c11336ba1 | ||
|
|
b19bd8406f | ||
|
|
cd46538eb8 | ||
|
|
eca5f395eb | ||
|
|
26767e925c | ||
|
|
01a0e40463 | ||
|
|
a11e833e06 | ||
|
|
b9bce36824 | ||
|
|
14c8a55211 | ||
|
|
5c5422fa02 | ||
|
|
9b041ec2ac | ||
|
|
6362283ade | ||
|
|
5f83c4a64f | ||
|
|
a9d8e3185e | ||
|
|
67a9f4db97 | ||
|
|
f07daccc82 | ||
|
|
2eef4f966f | ||
|
|
41003efb09 | ||
|
|
29a0f03a90 | ||
|
|
5a27aa0366 | ||
|
|
c99a55dc54 | ||
|
|
30d50f15d1 | ||
|
|
ab940acfb5 | ||
|
|
719b1fdd22 | ||
|
|
1fa2e116b3 | ||
|
|
35359f3575 | ||
|
|
fbf97df708 | ||
|
|
40237fae72 | ||
|
|
ad6a3c3147 | ||
|
|
25cfd94c56 | ||
|
|
3c20ee3c55 | ||
|
|
5e24bee4ab | ||
|
|
a014751cd4 | ||
|
|
7e057232e4 | ||
|
|
f5080e8a99 | ||
|
|
3d89f128ce | ||
|
|
b187efbedf | ||
|
|
dd23f06a8b |
No files matched your search
@@ -11,14 +11,14 @@ body:
|
||||
value: |
|
||||
Please include enough detail for someone else to reproduce the bug.
|
||||
|
||||
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
|
||||
Use any language you are comfortable with.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
Please write issues and pull requests yourself.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Mocktail version
|
||||
label: Nightcap version
|
||||
description: Provide the release version, package version, or commit hash.
|
||||
placeholder: "For example: v0.4.0 or 1a2b3c4"
|
||||
validations:
|
||||
@@ -37,7 +37,7 @@ body:
|
||||
id: installation
|
||||
attributes:
|
||||
label: Installation method
|
||||
description: Tell us how Mocktail was installed or built.
|
||||
description: Tell us how Nightcap was installed or built.
|
||||
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
|
||||
validations:
|
||||
required: true
|
||||
@@ -56,7 +56,7 @@ body:
|
||||
label: Steps to reproduce
|
||||
description: List the smallest reliable sequence of actions that triggers the problem.
|
||||
placeholder: |
|
||||
1. Start Mocktail with ...
|
||||
1. Start Nightcap with ...
|
||||
2. Open ...
|
||||
3. Select ...
|
||||
4. Observe ...
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
name: Feature request
|
||||
description: Suggest an improvement or a new capability for Mocktail
|
||||
description: Suggest an improvement or a new capability for Nightcap
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
@@ -10,9 +11,9 @@ body:
|
||||
value: |
|
||||
Describe what you want to change and why it would be useful.
|
||||
|
||||
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
|
||||
Use any language you are comfortable with.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
Please write issues and pull requests yourself.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -26,7 +27,7 @@ body:
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe how you would like Mocktail to behave.
|
||||
description: Describe how you would like Nightcap to behave.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
|
||||
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
|
||||
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
|
||||
|
||||
Closes #
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
@@ -8,22 +8,32 @@ on:
|
||||
- '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:
|
||||
@@ -65,3 +75,62 @@ jobs:
|
||||
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 }}, VR ${{ matrix.vr }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
runner: ubuntu-26.04
|
||||
vr: 'ON'
|
||||
- arch: aarch64
|
||||
runner: ubuntu-26.04-arm
|
||||
vr: 'ON'
|
||||
# Packaging recipes build without the OpenXR submodule; keep the
|
||||
# non-VR configuration compiling and passing on its own.
|
||||
- arch: x86_64
|
||||
runner: ubuntu-26.04
|
||||
vr: 'OFF'
|
||||
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 \
|
||||
-DMOCKTAIL_ENABLE_VR=${{ matrix.vr }}
|
||||
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
|
||||
@@ -38,6 +38,9 @@ jobs:
|
||||
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.
|
||||
|
||||
@@ -44,6 +44,8 @@ jobs:
|
||||
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
|
||||
@@ -60,6 +62,7 @@ jobs:
|
||||
--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 \
|
||||
@@ -123,11 +126,12 @@ jobs:
|
||||
export RUNTIME=/tmp/uruntime
|
||||
export DWARFS_CMD=/tmp/mkdwarfs
|
||||
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
||||
APPIMAGE=dist/Mocktail-x86_64.AppImage \
|
||||
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/Mocktail-x86_64.AppImage
|
||||
test -x dist/Nightcap-nightly-x86_64.AppImage
|
||||
test -s dist/Nightcap-nightly-x86_64.AppImage.zsync
|
||||
'
|
||||
|
||||
- name: Smoke-test AppImage without FUSE
|
||||
@@ -140,14 +144,17 @@ jobs:
|
||||
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
|
||||
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/Mocktail-x86_64.AppImage
|
||||
path: |
|
||||
dist/Nightcap-nightly-x86_64.AppImage
|
||||
dist/Nightcap-nightly-x86_64.AppImage.zsync
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -173,13 +180,14 @@ jobs:
|
||||
name: Nightcap-AppImage
|
||||
path: release
|
||||
|
||||
- name: Name and checksum the nightly build
|
||||
- name: Checksum the nightly build
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
# The Makefile builds under upstream's artifact name; the published
|
||||
# asset carries this fork's.
|
||||
mv -- release/Mocktail-x86_64.AppImage release/Nightcap-nightly-x86_64.AppImage
|
||||
# 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
|
||||
@@ -206,7 +214,7 @@ jobs:
|
||||
# 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.sha256)
|
||||
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.zsync|Nightcap-nightly-x86_64.AppImage.sha256)
|
||||
;;
|
||||
*)
|
||||
gh release delete-asset continuous "$asset" --yes
|
||||
|
||||
@@ -336,3 +336,15 @@ jobs:
|
||||
--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 }}
|
||||
@@ -4,3 +4,9 @@
|
||||
[submodule "third_party/Vulkan-Headers"]
|
||||
path = third_party/Vulkan-Headers
|
||||
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
|
||||
[submodule "third_party/smaa"]
|
||||
path = third_party/smaa
|
||||
url = https://github.com/iryoku/smaa.git
|
||||
+265
-140
@@ -1,158 +1,286 @@
|
||||
# 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.
|
||||
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.
|
||||
- `vr.mode` in `config.yaml`: `off` starts on the desktop, `on` starts in VR, and `auto` starts in VR only
|
||||
when SteamVR is already running with a connected headset. `auto` never starts SteamVR. `vr.enabled` still
|
||||
works.
|
||||
- Anti-aliasing in VR. Roblox draws the VR eyes without it, so edges were jagged in the headset. Each eye is
|
||||
now filtered on its way to the headset. `vr.anti_aliasing` picks `fxaa` (the default), `smaa`, which finds
|
||||
edges more precisely and keeps text sharper for a little more GPU time, or `off`.
|
||||
- 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.
|
||||
- `--profile` traces now show how much GPU memory decoded ETC2 textures take, as the `emulated texture (MB)`
|
||||
and `compressed texture (MB)` counters. `scripts/summarize_profile_trace.py` prints the peak of each.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Joining a game from a website link while already in a game no longer freezes Nightcap. It leaves the
|
||||
current game and joins the new one, and leaving that game returns to the home screen.
|
||||
|
||||
### Changed
|
||||
|
||||
- The home screen and avatar editor have fewer long frames. PC profiles compile the app's menu scripts to
|
||||
native code and turn this off while you are in a game, so games run as before. Set
|
||||
`FFlagDebugLuauCodegenAll` in `fflags.json` to choose it yourself.
|
||||
- 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.
|
||||
- VR on Vulkan no longer makes the CPU wait for the GPU each frame. The eye copies, the desktop mirror and
|
||||
the `MOCKTAIL_VR_MIRROR_SHM` readback are ordered on the GPU, so Roblox prepares the next frame while the
|
||||
last one renders. On a simulated 120 Hz headset this removed 2 to 5 ms of waiting per frame and steadied
|
||||
the frame rate. The shared-memory mirror now shows each frame one frame later.
|
||||
- `MOCKTAIL_VR_PERF` also reports `desktop_mirror`, `present_prepare` and `present_interval`.
|
||||
|
||||
## [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.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -160,37 +288,34 @@ 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/.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.1...HEAD
|
||||
[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
|
||||
|
||||
+25
-50
@@ -9,8 +9,8 @@
|
||||
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(Mocktail
|
||||
VERSION 1.0.4
|
||||
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
|
||||
VERSION 0.3.4
|
||||
DESCRIPTION "Android Roblox compatibility runtime for Linux"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
|
||||
@@ -126,6 +126,8 @@ set(VULKAN_HEADERS_ENABLE_INSTALL OFF CACHE BOOL
|
||||
"Install vendored Vulkan-Headers" FORCE)
|
||||
add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)
|
||||
|
||||
include(${CMAKE_SOURCE_DIR}/cmake/MocktailVR.cmake)
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
# Package builds disable tests and do not fetch Google Test.
|
||||
@@ -249,6 +251,10 @@ mocktail_apply_compile_options(mocktail_updater)
|
||||
|
||||
add_library(mocktail_legacy_jnivm STATIC
|
||||
src/jnivm/jnivm.cc
|
||||
src/jnivm/jni_references.cc
|
||||
src/jnivm/jni_fields.cc
|
||||
src/jnivm/jni_strings.cc
|
||||
src/jnivm/jni_method_names.cc
|
||||
)
|
||||
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
|
||||
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
|
||||
@@ -275,6 +281,7 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/game_session_coordinator.cc
|
||||
src/runtime/graphics_launch_policy.cc
|
||||
src/runtime/http_client_policy.cc
|
||||
src/runtime/luau_codegen_switch.cc
|
||||
src/runtime/memory_limit.cc
|
||||
src/runtime/owned_pthread.cc
|
||||
src/runtime/performance_policy.cc
|
||||
@@ -285,12 +292,18 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/roblox_capability_resolver.cc
|
||||
src/runtime/roblox_experience_launch_bridge.cc
|
||||
src/runtime/roblox_launch_uri.cc
|
||||
src/runtime/roblox_presence_resolver.cc
|
||||
src/runtime/roblox_game_session_native_adapter.cc
|
||||
src/runtime/roblox_web_view_bridge.cc
|
||||
src/runtime/roblox_permissions_bridge.cc
|
||||
src/runtime/roblox_account_bridge.cc
|
||||
src/runtime/roblox_message_bus_request_bridge.cc
|
||||
src/runtime/roblox_call_protocol_bridge.cc
|
||||
src/runtime/roblox_haptic_protocol_bridge.cc
|
||||
src/runtime/payload_update_preflight.cc
|
||||
src/runtime/process_launch_policy.cc
|
||||
src/runtime/process_diagnostics.cc
|
||||
src/runtime/asset_overlay_names.cc
|
||||
src/runtime/runtime_config.cc
|
||||
src/runtime/runtime_config_bootstrap.cc
|
||||
src/runtime/runtime_config_file.cc
|
||||
@@ -385,7 +398,8 @@ add_library(mocktail_webview_helper_policy STATIC
|
||||
src/webview/webview_helper_policy.cc
|
||||
)
|
||||
add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy)
|
||||
target_include_directories(mocktail_webview_helper_policy PUBLIC src)
|
||||
target_include_directories(mocktail_webview_helper_policy PUBLIC src
|
||||
PRIVATE include)
|
||||
target_link_libraries(mocktail_webview_helper_policy PUBLIC
|
||||
PkgConfig::WEBKITGTK6
|
||||
nlohmann_json::nlohmann_json
|
||||
@@ -472,7 +486,7 @@ target_link_libraries(mocktail_window PUBLIC
|
||||
Mocktail::SdlVulkanWsi
|
||||
SDL3::SDL3
|
||||
${CMAKE_DL_LIBS}
|
||||
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
|
||||
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay Mocktail::GraphicsFoundation
|
||||
)
|
||||
|
||||
add_library(mocktail_roblox_output_device_runtime STATIC
|
||||
@@ -553,50 +567,6 @@ target_link_libraries(mocktail_bionic_abi_exports PRIVATE
|
||||
Threads::Threads
|
||||
)
|
||||
|
||||
include(CheckCXXSourceCompiles)
|
||||
include(CheckIncludeFileCXX)
|
||||
include(CMakePushCheckState)
|
||||
|
||||
check_include_file_cxx(execinfo.h MOCKTAIL_HAVE_EXECINFO_H)
|
||||
if(NOT MOCKTAIL_HAVE_EXECINFO_H)
|
||||
message(FATAL_ERROR
|
||||
"execinfo.h is required for crash diagnostics; on musl install "
|
||||
"libexecinfo-devel"
|
||||
)
|
||||
endif()
|
||||
|
||||
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "")
|
||||
set(_mocktail_execinfo_link_probe [=[
|
||||
#include <execinfo.h>
|
||||
int main() {
|
||||
void* frames[1] = {};
|
||||
const int count = backtrace(frames, 1);
|
||||
return backtrace_symbols(frames, count) == nullptr;
|
||||
}
|
||||
]=])
|
||||
check_cxx_source_compiles(
|
||||
"${_mocktail_execinfo_link_probe}"
|
||||
MOCKTAIL_EXECINFO_IN_HOST_LIBC
|
||||
)
|
||||
if(NOT MOCKTAIL_EXECINFO_IN_HOST_LIBC)
|
||||
find_library(MOCKTAIL_EXECINFO_LIBRARY NAMES execinfo REQUIRED)
|
||||
cmake_push_check_state(RESET)
|
||||
set(CMAKE_REQUIRED_LIBRARIES "${MOCKTAIL_EXECINFO_LIBRARY}")
|
||||
check_cxx_source_compiles(
|
||||
"${_mocktail_execinfo_link_probe}"
|
||||
MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY
|
||||
)
|
||||
cmake_pop_check_state()
|
||||
if(NOT MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY)
|
||||
message(FATAL_ERROR
|
||||
"execinfo.h is available, but backtrace and backtrace_symbols cannot "
|
||||
"be linked"
|
||||
)
|
||||
endif()
|
||||
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "${MOCKTAIL_EXECINFO_LIBRARY}")
|
||||
endif()
|
||||
unset(_mocktail_execinfo_link_probe)
|
||||
|
||||
add_library(mocktail_legacy_runtime STATIC
|
||||
src/legacy/legacy_runtime.cc
|
||||
src/runtime/roblox_text_input_jni_bridge.cc
|
||||
@@ -621,7 +591,6 @@ target_link_libraries(mocktail_legacy_runtime PRIVATE
|
||||
libc_shim
|
||||
mocktail_window
|
||||
mocktail_graphics_foundation
|
||||
${MOCKTAIL_EXECINFO_LINK_LIBRARY}
|
||||
)
|
||||
target_compile_definitions(mocktail_legacy_runtime PRIVATE
|
||||
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
||||
@@ -634,6 +603,7 @@ target_compile_definitions(mocktail PRIVATE
|
||||
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
|
||||
)
|
||||
target_link_libraries(mocktail PRIVATE
|
||||
Mocktail::VR
|
||||
mocktail_legacy_runtime
|
||||
mocktail_auth_runtime_composition
|
||||
mocktail_failure_dialog_runtime
|
||||
@@ -650,6 +620,11 @@ target_link_options(mocktail PRIVATE
|
||||
)
|
||||
|
||||
# Native FreeBSD helper for Linuxulator's unsupported DF socket option.
|
||||
# The helper is x86-64 assembly; other host architectures have nothing to
|
||||
# assemble.
|
||||
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$")
|
||||
set(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER OFF)
|
||||
endif()
|
||||
option(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER
|
||||
"Build the native FreeBSD Linuxulator socket helper" ON)
|
||||
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER)
|
||||
@@ -811,7 +786,7 @@ set(CPACK_PACKAGE_CONTACT "vii <coderdayton14@gmail.com>")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CoderDayton/nightcap")
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}")
|
||||
set(CPACK_PACKAGE_DESCRIPTION
|
||||
"Runs the Android x86-64 Roblox client on Linux."
|
||||
"Runs the Android Roblox client on Linux."
|
||||
)
|
||||
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")
|
||||
set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
|
||||
|
||||
@@ -42,17 +42,28 @@ attach `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?
|
||||
|
||||
VR is experimental and lives on upstream's `vr` branch, which does not
|
||||
include the Nightcap changes. Install the
|
||||
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 remote add upstream https://github.com/komaruworld/mocktail.git
|
||||
git fetch upstream
|
||||
git switch -c vr upstream/vr
|
||||
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
|
||||
@@ -61,5 +72,8 @@ cmake --build build -j4
|
||||
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
|
||||
|
||||
```bash
|
||||
./build/mocktail -vr
|
||||
./build/mocktail --vr
|
||||
```
|
||||
|
||||
The [README](README.md#experimental-vr) and [docs/VR.md](docs/VR.md) have the
|
||||
details, including how to try it without a headset.
|
||||
@@ -21,7 +21,7 @@ ANYLINUX_APPIMAGETOOL ?= appimagetool
|
||||
PREFIX ?= /usr
|
||||
FLATPAK_BUILD_DIR ?= build-flatpak
|
||||
FLATPAK_MANIFEST ?= packaging/flatpak/io.github.CoderDayton.nightcap.json
|
||||
FLATPAK_JOBS ?= 4
|
||||
FLATPAK_JOBS ?= 2
|
||||
|
||||
define build_native_runtime
|
||||
@git submodule update --init --recursive
|
||||
@@ -130,8 +130,8 @@ auto-run: ## Build/run loop with crash summaries
|
||||
run-gles: build ## Run strict system EGL/OpenGL ES 3 without Vulkan fallback
|
||||
@$(BINARY) --graphics opengl
|
||||
|
||||
run-angle: ## Run with ANGLE/Vulkan compatibility backend
|
||||
@MOCKTAIL_GRAPHICS_BACKEND=angle-vulkan ./scripts/run_sober.sh
|
||||
run-angle: build ## Run with ANGLE/Vulkan compatibility backend
|
||||
@$(BINARY) --graphics angle-vulkan
|
||||
|
||||
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
|
||||
@./scripts/update_roblox_payload.sh
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
|
||||
# Nightcap
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
|
||||
<h1 style="margin-top: 5px;">
|
||||
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>
|
||||
|
||||
[](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml)
|
||||
[](https://github.com/CoderDayton/nightcap/stargazers)
|
||||
[](LICENSE)
|
||||
|
||||
Nightcap is Mocktail, tuned for playing on a real PC. 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 Roblox on Linux, tuned for desktop play. It is a Mocktail fork
|
||||
that keeps up with Roblox updates, with extra bug fixes, custom assets and
|
||||
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
|
||||
Corporation or VinegarHQ and does not distribute the Roblox client.
|
||||
@@ -21,23 +29,25 @@ with extra fixes for playing on a PC. The program and config paths keep the
|
||||
`io.github.CoderDayton.nightcap`, so Nightcap and upstream can be installed
|
||||
side by side.
|
||||
|
||||
- **Lower input lag.** Frames are shown as soon as they are ready instead of
|
||||
waiting in a queue.
|
||||
- **Sharper textures.** Small textures are upscaled 4x by default, and you
|
||||
can replace any texture with your own PNG in `~/.config/mocktail/textures`.
|
||||
A ready-made crosshair is in [`textures/`](textures/README.md). Set
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn the upscale off.
|
||||
- **ETC2 textures on any GPU.** Games that use compressed mobile textures now
|
||||
render correctly on desktop GPUs, without stutter.
|
||||
- **No more error 319 kicks.** Servers no longer drop you shortly after
|
||||
joining.
|
||||
- **Correct device info.** Roblox sees your real RAM and screen size.
|
||||
- **Better desktop launcher.** Runs on Wayland and always finds the installed
|
||||
binary.
|
||||
- **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 the
|
||||
[roadmap](docs/ROADMAP.md) for what is coming next, the
|
||||
[changelog](CHANGELOG.md) for what each release changed,
|
||||
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.
|
||||
|
||||
@@ -68,14 +78,23 @@ Each release also carries a DEB, an RPM, and a pacman package, with a
|
||||
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
|
||||
|
||||
```bash
|
||||
sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||
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. Nightcap is not in any distribution repository or the AUR; the
|
||||
recipes under `packaging/aur` are kept for local builds only.
|
||||
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).
|
||||
@@ -90,6 +109,12 @@ 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>
|
||||
|
||||
@@ -97,8 +122,6 @@ an update fails.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## FFlag overrides
|
||||
@@ -110,6 +133,14 @@ 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.
|
||||
|
||||
PC profiles compile the app's menus to native code with
|
||||
`FFlagDebugLuauCodegenAll`, and turn it off while you are in a game. Setting
|
||||
it yourself turns the switching off and your value applies everywhere. See
|
||||
[docs/PERFORMANCE.md](docs/PERFORMANCE.md#native-code-in-the-menus).
|
||||
|
||||
```json
|
||||
{
|
||||
"FFlagExample": "True",
|
||||
@@ -122,6 +153,8 @@ 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.
|
||||
- `vr.frame_rate_limit` sets the cap in VR. It defaults to `unlimited`, so the
|
||||
headset's refresh rate paces frames; use `-1` for Roblox's 60 FPS cap.
|
||||
- `graphics.backend: opengl` if Vulkan does not work on your GPU.
|
||||
|
||||
## Building
|
||||
@@ -192,6 +225,11 @@ 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:
|
||||
|
||||
@@ -199,6 +237,59 @@ To run the same checks as CI before each commit and push, install
|
||||
lefthook install
|
||||
```
|
||||
|
||||
## Experimental VR
|
||||
|
||||
Experimental OpenXR VR support on Linux `x86_64` using WiVRn or SteamVR/ALVR.
|
||||
|
||||
VR works on these Roblox builds. On any other build, `--vr` stops at start-up
|
||||
and names them:
|
||||
|
||||
| Build | Roblox version |
|
||||
| --- | --- |
|
||||
| 2998 | 2.736.1408 |
|
||||
| 3092 | 2.738.1397 |
|
||||
| 3222 | 2.742.1059 |
|
||||
|
||||
### Build with VR
|
||||
|
||||
VR support is compiled in when the OpenXR SDK submodule is checked out
|
||||
(`MOCKTAIL_ENABLE_VR` defaults to `AUTO`). Without the submodule the build
|
||||
skips VR. Pass `-DMOCKTAIL_ENABLE_VR=ON` to make a missing submodule an error,
|
||||
or `OFF` to leave VR out:
|
||||
|
||||
```bash
|
||||
git submodule update --init third_party/OpenXR-SDK
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build -j4
|
||||
```
|
||||
|
||||
VR stays off at runtime until you pass `--vr` or set `vr.mode: on` in
|
||||
`config.yaml`. `vr.mode: auto` starts in VR only when SteamVR is running with a
|
||||
headset connected.
|
||||
|
||||
### Run
|
||||
|
||||
Start WiVRn or SteamVR/ALVR and connect your headset first:
|
||||
|
||||
```bash
|
||||
./build/mocktail --vr
|
||||
```
|
||||
|
||||
The OpenXR runtime is detected automatically. To pick one manually:
|
||||
|
||||
```bash
|
||||
MOCKTAIL_VR_RUNTIME=wivrn ./build/mocktail --vr
|
||||
# or
|
||||
MOCKTAIL_VR_RUNTIME=alvr MOCKTAIL_GRAPHICS_BACKEND=direct-vulkan ./build/mocktail --vr
|
||||
```
|
||||
|
||||
If you previously set `XR_RUNTIME_JSON`, unset it before switching runtimes.
|
||||
Use `--no-vr` for desktop mode.
|
||||
|
||||
A keyboard opens in the headset when a text box takes focus. The rest is in
|
||||
[docs/VR.md](docs/VR.md): menus and typing, every VR environment variable,
|
||||
adding a Roblox build, and testing without a headset.
|
||||
|
||||
## Known limitations
|
||||
|
||||
Nightcap does not currently work with `hardened_malloc`. Using
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1009 KiB |
@@ -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_surface_overlay.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)
|
||||
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
|
||||
Mocktail::PlatformSdl
|
||||
Mocktail::Runtime
|
||||
Mocktail::VR
|
||||
mocktail_window
|
||||
PkgConfig::UTF8PROC
|
||||
PRIVATE
|
||||
PkgConfig::FONTCONFIG
|
||||
SDL3_ttf::SDL3_ttf
|
||||
nlohmann_json::nlohmann_json
|
||||
${CMAKE_DL_LIBS}
|
||||
)
|
||||
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
|
||||
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
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_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
|
||||
Mocktail::InputRuntime
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
|
||||
include_guard(GLOBAL)
|
||||
|
||||
# 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/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()
|
||||
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc
|
||||
src/vr/openxr_backend.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
|
||||
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})
|
||||
# SMAA's area and search lookup textures for vr.anti_aliasing: smaa.
|
||||
if(NOT EXISTS "${CMAKE_SOURCE_DIR}/third_party/smaa/Textures/AreaTex.h")
|
||||
message(FATAL_ERROR
|
||||
"Initialize SMAA: git submodule update --init third_party/smaa")
|
||||
endif()
|
||||
target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/third_party/smaa")
|
||||
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)
|
||||
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()
|
||||
@@ -52,6 +52,28 @@ graphics:
|
||||
# unless frame_rate_limit is unlimited.
|
||||
# vsync: off
|
||||
|
||||
# vr:
|
||||
# String (default: off): experimental VR through OpenXR on supported Roblox
|
||||
# builds. off always starts on the desktop. on always 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
|
||||
# String (default: fxaa): smooth jagged edges in the headset. Roblox draws
|
||||
# the VR eyes without anti-aliasing, so each eye is filtered on its way to
|
||||
# the headset. fxaa is the cheapest. smaa finds edges more precisely and
|
||||
# keeps text and fine detail sharper, at a little more GPU time. 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:
|
||||
# Boolean (default: false): size Roblox scheduler/render queues from every
|
||||
# physical CPU core. A place's Lua/main thread can still remain serial.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
"version_name": "2.721.1108",
|
||||
"version_code": 2350,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "50e1b0abd123350e794226062fe3a1ef360c5f0d",
|
||||
"status": "legacy-researched",
|
||||
"default_allowed": false,
|
||||
@@ -15,6 +16,7 @@
|
||||
{
|
||||
"version_name": "2.725.1142",
|
||||
"version_code": 2546,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -35,6 +37,7 @@
|
||||
{
|
||||
"version_name": "2.727.1199",
|
||||
"version_code": 2628,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -46,6 +49,7 @@
|
||||
{
|
||||
"version_name": "2.734.917",
|
||||
"version_code": 2908,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -66,6 +70,19 @@
|
||||
{
|
||||
"version_name": "2.736.1408",
|
||||
"version_code": 2998,
|
||||
"abi": "arm64-v8a",
|
||||
"elf_build_id": "ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
"allow_legacy_binary_patches": false,
|
||||
"allow_host_abi_bridges": 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."
|
||||
},
|
||||
{
|
||||
"version_name": "2.736.1408",
|
||||
"version_code": 2998,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -85,7 +102,46 @@
|
||||
"input_current_method_rva": "0x32d13a6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"mode": "vendored-patched-snapshot",
|
||||
"url": "https://github.com/minecraft-linux/mcpelauncher-linker.git",
|
||||
"upstream_commit": "1ac3ea6c1cf4f73a84d73f0bff510fba94a7d0f2",
|
||||
"vendor_tree": "fca44d85e3773c135ccfc603b14abdf6aeb8efd1",
|
||||
"vendor_tree": "0637221e48f382ec8d0bc701385f4b10bcd07a65",
|
||||
"patch": "third_party/patches/mcpelauncher-linker.patch",
|
||||
"nested_sources": {
|
||||
"android_bionic": {
|
||||
|
||||
+20
-1
@@ -46,15 +46,34 @@ Each slice is named after the call it times, on the thread that made it.
|
||||
| `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. | |
|
||||
| `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.
|
||||
|
||||
`emulated texture (MB)` is the VRAM held by the decoded images that stand in
|
||||
for live ETC2/EAC textures, over every mip level and layer, upscaled where
|
||||
small-texture upscale applies. `compressed texture (MB)` is the size the same
|
||||
textures would take as ETC2/EAC. Both change only when such an image is
|
||||
created or destroyed. `summarize_profile_trace.py` prints each one's peak.
|
||||
|
||||
## The benchmark runs
|
||||
|
||||
Run every scenario before and after the change, on the same build type
|
||||
|
||||
+67
-2
@@ -129,14 +129,23 @@ 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
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) stores 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.
|
||||
|
||||
Upscaling alone adds no detail. Each original pixel becomes an N×N block of the
|
||||
same colour, and the GPU's filtering already blends a small texture that much
|
||||
when it draws it. The extra size only shows when a replacement PNG in
|
||||
`~/.config/mocktail/textures` has more detail than the original.
|
||||
|
||||
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.
|
||||
extra sampling bandwidth on every frame that uses them. In Blade Ball the
|
||||
memory share is small: decoded textures came to 5.1 times their ETC2 size both
|
||||
with the default and with upscaling off. The `emulated texture (MB)` and
|
||||
`compressed texture (MB)` counters in a `--profile` trace give the figures for
|
||||
any game.
|
||||
|
||||
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
|
||||
@@ -151,12 +160,68 @@ Set it to `1` to turn upscaling off:
|
||||
MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail
|
||||
```
|
||||
|
||||
## Native code in the menus
|
||||
|
||||
The home screen and avatar editor run Roblox's Lua app in the Luau interpreter,
|
||||
and its long frames come from that thread. PC profiles set
|
||||
`FFlagDebugLuauCodegenAll`, which compiles every script to native code. That
|
||||
makes the menus' Lua about 2.5 times cheaper for about 56 MB of compiled code.
|
||||
|
||||
In a game the same flag compiles every game script too: Blade Ball grew from 7
|
||||
MB to about 300 MB and hit Roblox's native code limit, which shows warnings in
|
||||
F9. So Nightcap turns the flag off when a game starts joining and back on when
|
||||
you return to the menus. Roblox reads the flag each time it compiles a script,
|
||||
and a game's compiled code is freed when you leave it. Games run with Roblox's
|
||||
own per-script choice, the same as without the flag.
|
||||
|
||||
The log shows each switch:
|
||||
|
||||
```text
|
||||
[luau-codegen] menus-only switch at +0x<offset in libroblox.so>
|
||||
[luau-codegen] native code for all scripts off
|
||||
[luau-codegen] native code for all scripts on
|
||||
```
|
||||
|
||||
Setting `FFlagDebugLuauCodegenAll` in `fflags.json` turns the switching off
|
||||
and your value applies everywhere. To keep the interpreter in the menus too:
|
||||
|
||||
```json
|
||||
{
|
||||
"FFlagDebugLuauCodegenAll": false
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
In VR, `vr.frame_rate_limit` (`MOCKTAIL_VR_FRAME_RATE_LIMIT`) replaces
|
||||
`graphics.frame_rate_limit` and takes the same values. Its default is
|
||||
`unlimited`: the scheduler target goes to 240 and `xrWaitFrame` holds each
|
||||
frame to the headset's refresh rate. Set `-1` to restore Roblox's own cap, or a
|
||||
number such as `90` to pin it.
|
||||
|
||||
`MOCKTAIL_VR_RENDER_SCALE` (default `1.0`, range `0.25`–`2.0`) scales each
|
||||
axis of the OpenXR runtime's recommended eye size. SteamVR's recommendation
|
||||
already includes its own supersampling, so on a Quest 2 at 90 Hz it is about
|
||||
2244x2352 per eye. If a frame misses the 11.1 ms budget, the runtime halves
|
||||
the rate to 45 FPS; `0.75` renders about 56% of the pixels.
|
||||
|
||||
`MOCKTAIL_VR_UNPACED=1` is a benchmark mode. Roblox no longer waits for the
|
||||
headset between frames; each XR frame stays open across several Roblox frames
|
||||
and is submitted with the newest eyes once per display period. Pair it with a
|
||||
fixed scheduler target above 240 and `MOCKTAIL_VSYNC=off` to find the
|
||||
maximum rate:
|
||||
|
||||
```bash
|
||||
MOCKTAIL_VR_UNPACED=1 MOCKTAIL_VR_FRAME_RATE_LIMIT=9999 MOCKTAIL_VSYNC=off mocktail --vr
|
||||
```
|
||||
|
||||
Frames beyond the headset refresh are never displayed, and they all reuse the
|
||||
open frame's head pose, so expect judder. Do not play in this mode.
|
||||
|
||||
`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
|
||||
|
||||
+22
-45
@@ -16,75 +16,52 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
|
||||
- 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.
|
||||
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.
|
||||
|
||||
## 2. Controller button glyphs
|
||||
|
||||
Upstream added Xbox and PlayStation gamepad support through SDL. Controllers
|
||||
already hot-plug and release their held inputs when unplugged. One rough edge
|
||||
is left.
|
||||
|
||||
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
|
||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
||||
and product IDs, are available and unused.
|
||||
|
||||
Done when a plugged-in pad shows its own buttons.
|
||||
|
||||
## 3. First-load stutter
|
||||
|
||||
The `--profile` trace now shows shader compiles and pipeline creation, so
|
||||
the stutter on first entering an experience can be measured.
|
||||
|
||||
- Cut the stutter the trace shows, and prove it with a before and after
|
||||
trace per [BENCHMARKING.md](BENCHMARKING.md).
|
||||
|
||||
Done when entering a fresh experience no longer drops frames on shader
|
||||
compiles.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
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.
|
||||
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`.
|
||||
`~/.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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
upstream.
|
||||
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
|
||||
repo on GitHub Pages.
|
||||
repo on GitHub Pages.
|
||||
|
||||
## Not planned
|
||||
|
||||
- Replacing the `mocktail` binary and config names. Keeping them makes
|
||||
upstream merges clean.
|
||||
- DEB, RPM, and AUR packages. Upstream ships those for unmodified Mocktail.
|
||||
upstream merges clean.
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# VR
|
||||
|
||||
Experimental OpenXR VR is opt-in: launch with `--vr`, or set `vr.mode` in
|
||||
`config.yaml`:
|
||||
|
||||
- `off` (default) always starts on the desktop.
|
||||
- `on` always starts in VR.
|
||||
- `auto` starts in VR when SteamVR is already running with a connected
|
||||
headset, ALVR included, and on the desktop otherwise. It never starts
|
||||
SteamVR, so start SteamVR and connect the headset first.
|
||||
|
||||
`--vr` and `--no-vr` override `vr.mode` for one launch. The older
|
||||
`vr.enabled: true`/`false` still works as `on`/`off`. Everything here only
|
||||
applies while VR is active. The [README](../README.md#experimental-vr) covers
|
||||
building and running.
|
||||
|
||||
## Menus and typing
|
||||
|
||||
The home menu uses Roblox's `More` button. The desktop side panel is turned
|
||||
off in VR, because the VR pointer cannot click it.
|
||||
|
||||
When a text box takes focus (chat, search), a keyboard opens in front of you,
|
||||
below eye level. It is a full US layout without the function row or
|
||||
Ctrl/Alt. Point at a key and pull the trigger. While you point at the keyboard,
|
||||
the trigger types and does not click the game behind it. Hold Backspace to
|
||||
delete repeatedly. Shift applies to the next key and selects its upper symbol;
|
||||
Caps Lock stays on until you tap it again. Close dismisses the keyboard and
|
||||
leaves the text box. Set `MOCKTAIL_VR_KEYBOARD=0` to turn the keyboard off.
|
||||
|
||||
## Environment variables
|
||||
|
||||
## Runtime and display
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `MOCKTAIL_VR_ENABLED` | `0` | Same as `--vr` (`1`) or `--no-vr` (`0`). |
|
||||
| `MOCKTAIL_VR_RUNTIME` | `auto` | OpenXR runtime: `auto`, `system`, `wivrn`, `steamvr` or `alvr`. `XR_RUNTIME_JSON` overrides it. |
|
||||
| `MOCKTAIL_VR_BACKEND` | `xr` | `native` keeps Roblox's stereo rendering without an OpenXR runtime, for debugging. |
|
||||
| `MOCKTAIL_VR_FRAME_RATE_LIMIT` | `unlimited` | Frame-rate cap in VR, same values as `graphics.frame_rate_limit` (also `vr.frame_rate_limit`). With `unlimited` the headset's refresh rate paces frames. |
|
||||
| `MOCKTAIL_VR_ANTI_ALIASING` | `fxaa` | Same as `vr.anti_aliasing`. Roblox draws the VR eyes without anti-aliasing, so each eye is filtered on its way to the headset (Vulkan only). `fxaa` is the cheapest; `smaa` finds edges more precisely and keeps text sharper; `off` sends them as rendered. |
|
||||
| `MOCKTAIL_VR_RENDER_SCALE` | `1.0` | Scales each axis of the runtime's recommended eye size, `0.25` to `2.0`. |
|
||||
| `MOCKTAIL_VR_UNPACED` | `0` | `1` stops waiting for the headset between frames, to measure the maximum frame rate. Frames past the headset refresh are not shown. Not for playing. |
|
||||
| `MOCKTAIL_VR_DESKTOP_MIRROR` | `1` | `0` stops copying the left eye to the desktop window. |
|
||||
| `MOCKTAIL_VR_MIRROR_SHM` | unset | A shared-memory name such as `/nightcap-mirror` publishes both eyes for an external viewer. The segment is private to your user. |
|
||||
| `MOCKTAIL_VR_KEYBOARD` | `1` | `0` turns off the in-headset keyboard. |
|
||||
|
||||
## Hands and scale
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `MOCKTAIL_VR_HAND_POSE_SOURCE` | `aim` | `grip` orients hands from the controller grip pose instead of the aim pose. |
|
||||
| `MOCKTAIL_VR_HAND_PITCH_OFFSET` | `-20` | Degrees added to Roblox's own hand pitch. |
|
||||
| `MOCKTAIL_VR_HAND_PITCH` | unset | Replaces the total hand pitch, in degrees. |
|
||||
| `MOCKTAIL_VR_WORLD_SCALE` | `1.0` | Scales head, hand and eye offsets. `MOCKTAIL_VR_EYE_OFFSET_SCALE` is an older name for it. |
|
||||
|
||||
## Performance tracing
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `MOCKTAIL_VR_PERF` | unset | `1` logs per-stage frame timings and counters once a second. `present_interval` is the frame time; `desktop_mirror`, `copy_record` and `copy_fence_wait` show time the CPU spent on VR copies. |
|
||||
| `MOCKTAIL_VR_MEASURE_START_NS`, `MOCKTAIL_VR_MEASURE_END_NS` | unset | Limit `MOCKTAIL_VR_PERF` to a `CLOCK_MONOTONIC` window. |
|
||||
| `MOCKTAIL_VR_POSE_LOG_INTERVAL` | `240` | Frames between head-pose log lines. |
|
||||
|
||||
## Diagnostics builds
|
||||
|
||||
These only work in builds configured with `-DMOCKTAIL_VR_DIAGNOSTICS=ON`.
|
||||
Release builds ignore them.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `MOCKTAIL_VR_POSE_SOURCE` | `runtime` | `script` drives the head from a scripted motion instead of the headset, for testing without wearing it. |
|
||||
| `MOCKTAIL_VR_POSE_AMPLITUDE_POSITION`, `_YAW`, `_PITCH` | `0.25`, `0.5`, `0.17` | Scripted motion size, in metres and radians. |
|
||||
| `MOCKTAIL_VR_POSE_PERIOD_POSITION`, `_ROTATION` | `240`, `360` | Scripted motion period, in frames. |
|
||||
| `MOCKTAIL_VR_XR_EVIDENCE_DIR` | unset | Writes submitted eye images to this directory. |
|
||||
| `MOCKTAIL_VR_XR_EVIDENCE_INTERVAL` | `20` | Frames between evidence images. |
|
||||
|
||||
## Adding a Roblox build
|
||||
|
||||
VR needs addresses that are specific to each Roblox build. To use a supported
|
||||
build that is not the active one, point `--roblox-lib` at its `libroblox.so`.
|
||||
|
||||
To add a build, run the offset finder on its library:
|
||||
|
||||
```bash
|
||||
python3 scripts/find_roblox_vr_offsets.py --elf <libroblox.so> --output offsets.json
|
||||
```
|
||||
|
||||
It needs the `capstone` Python package. Add its values to `kContracts` in
|
||||
`src/vr/roblox_vr_device_bridge.cc`. Nightcap checks those addresses against
|
||||
Roblox's code before it hooks anything, and leaves VR off if they do not
|
||||
match. To run the same checks on real libraries, set
|
||||
`MOCKTAIL_TEST_VR_PAYLOADS` to a folder of `<build>-<build id>/libroblox.so`
|
||||
payloads and run `ctest -R VrSupportedBuilds`.
|
||||
|
||||
For a second opinion, solve the new build from the previous one and compare
|
||||
the two answers. It needs the previous build's HostAbi profile and
|
||||
compatibility manifest:
|
||||
|
||||
```bash
|
||||
python3 scripts/roblox_abi_check.py --reference-lib <old libroblox.so> \
|
||||
--reference-profile <old profile.json> \
|
||||
--reference-compatibility config/roblox_compatibility.json \
|
||||
--candidate-lib <new libroblox.so> --vr
|
||||
```
|
||||
|
||||
It lists every field where the finder and the solver disagree and exits 0
|
||||
when they agree on all of them.
|
||||
|
||||
## Testing without a headset
|
||||
|
||||
[Monado](https://monado.freedesktop.org) can stand in for a headset. Its
|
||||
`qwerty` driver gives a head and two controllers that you move with the
|
||||
keyboard and mouse:
|
||||
|
||||
```bash
|
||||
QWERTY_ENABLE=1 XRT_DEBUG_GUI=1 monado-service
|
||||
```
|
||||
|
||||
Then, in another terminal:
|
||||
|
||||
```bash
|
||||
XR_RUNTIME_JSON=/usr/share/openxr/1/openxr_monado.json ./build/mocktail --vr
|
||||
```
|
||||
|
||||
The keys work while Monado's debug window has focus. The simulated display is
|
||||
small, so text in the headset view looks coarse.
|
||||
|
||||
Two tests need an OpenXR runtime and skip without one. To run them, start
|
||||
Monado with no window and opt in:
|
||||
|
||||
```bash
|
||||
XRT_COMPOSITOR_NULL=1 monado-service &
|
||||
XR_RUNTIME_JSON=/usr/share/openxr/1/openxr_monado.json MOCKTAIL_TEST_XR_GLES=1 \
|
||||
ctest --test-dir build -R GlesVrTransportTest.OpenXr
|
||||
```
|
||||
@@ -49,6 +49,17 @@
|
||||
gdb
|
||||
];
|
||||
|
||||
desktopItems = [
|
||||
(pkgs.makeDesktopItem {
|
||||
name = "Mocktail";
|
||||
exec = "mocktail";
|
||||
icon = "mocktail";
|
||||
desktopName = "Mocktail";
|
||||
comment = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
|
||||
categories = [ "Game" ];
|
||||
})
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
|
||||
|
||||
|
||||
@@ -33,6 +33,23 @@ void SetLegacyBionicDiagnosticsEnabled(bool enabled);
|
||||
// guest layout explicit instead of exposing the incompatible host libc type.
|
||||
using MocktailBionicMutexAttr = int64_t;
|
||||
|
||||
// Bionic LP64 pthread_attr_t: 56 bytes with explicit field offsets. The host
|
||||
// glibc layout is opaque and wider on aarch64, so guest buffers must be read
|
||||
// and written through this struct only.
|
||||
struct MocktailBionicPthreadAttr {
|
||||
std::uint32_t flags;
|
||||
void* stack_base;
|
||||
std::size_t stack_size;
|
||||
std::size_t guard_size;
|
||||
std::int32_t sched_policy;
|
||||
std::int32_t sched_priority;
|
||||
char reserved[16];
|
||||
};
|
||||
static_assert(sizeof(MocktailBionicPthreadAttr) == 56);
|
||||
|
||||
inline constexpr std::uint32_t kMocktailBionicAttrFlagDetached = 0x1;
|
||||
inline constexpr std::uint32_t kMocktailBionicAttrFlagUserStack = 0x2;
|
||||
|
||||
extern "C" {
|
||||
|
||||
void mocktail_set_current_jni_env(void* env);
|
||||
@@ -84,6 +101,19 @@ int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
|
||||
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
|
||||
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
|
||||
|
||||
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
|
||||
size_t stack_size);
|
||||
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
|
||||
int detach_state);
|
||||
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
|
||||
const struct sched_param* parameters);
|
||||
int mocktail_pthread_getattr_np(pthread_t thread,
|
||||
MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
|
||||
void** stack_base, size_t* stack_size);
|
||||
|
||||
extern void* mocktail_gameactivity_on_start_native;
|
||||
extern void* mocktail_gameactivity_on_resume_native;
|
||||
extern void* mocktail_gameactivity_on_surface_created_native;
|
||||
|
||||
@@ -11,19 +11,27 @@
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// glibc's atoi is (int)strtol(), exactly like Bionic's, so the host symbol is
|
||||
// used as-is there. Other libcs (musl) differ on overflow and need the shim.
|
||||
#ifdef __GLIBC__
|
||||
inline constexpr bool kHostAtoiMatchesBionic = true;
|
||||
#else
|
||||
inline constexpr bool kHostAtoiMatchesBionic = false;
|
||||
#endif
|
||||
|
||||
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
|
||||
// An all-zero fallback means unknown, not zero usage.
|
||||
struct BionicMallinfoSnapshot {
|
||||
size_t arena = 0;
|
||||
size_t ordblks = 0;
|
||||
size_t smblks = 0;
|
||||
size_t hblks = 0;
|
||||
size_t hblkhd = 0;
|
||||
size_t usmblks = 0;
|
||||
size_t fsmblks = 0;
|
||||
size_t uordblks = 0;
|
||||
size_t fordblks = 0;
|
||||
size_t keepcost = 0;
|
||||
size_t arena;
|
||||
size_t ordblks;
|
||||
size_t smblks;
|
||||
size_t hblks;
|
||||
size_t hblkhd;
|
||||
size_t usmblks;
|
||||
size_t fsmblks;
|
||||
size_t uordblks;
|
||||
size_t fordblks;
|
||||
size_t keepcost;
|
||||
};
|
||||
|
||||
struct BionicLocaleState;
|
||||
@@ -90,6 +98,7 @@ int mocktail_bionic_cxa_thread_atexit_impl(
|
||||
mocktail::compat::BionicThreadDestructor destructor, void* argument,
|
||||
void* dso_handle);
|
||||
void mocktail_bionic_arc4random_buf(void* buffer, size_t size);
|
||||
int mocktail_bionic_atoi(const char* text);
|
||||
mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo();
|
||||
int mocktail_bionic_sysinfo(struct sysinfo* info);
|
||||
int mocktail_bionic_uname(struct utsname* name);
|
||||
|
||||
@@ -25,11 +25,12 @@ void ConfigureBionicPthreadThreadInitializer(
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
struct MocktailBionicPthreadAttr;
|
||||
|
||||
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
|
||||
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
|
||||
const pthread_attr_t* attr,
|
||||
void* (*start_routine)(void*),
|
||||
void* argument);
|
||||
extern "C" int mocktail_bionic_pthread_create(
|
||||
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument);
|
||||
|
||||
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
|
||||
extern "C" int mocktail_bionic_pthread_setschedparam(
|
||||
|
||||
@@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile {
|
||||
? std::array<std::size_t, 4>{9, 10, 12, 18}
|
||||
: std::array<std::size_t, 4>{8, 9, 10, 16};
|
||||
}
|
||||
std::array<std::size_t, 6> capture_vtable_indexes() const {
|
||||
return vtable_layout_version == 2
|
||||
? std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17}
|
||||
: std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15};
|
||||
}
|
||||
|
||||
bool valid_vtable_layout() const {
|
||||
return vtable_layout_version == 1 || vtable_layout_version == 2;
|
||||
@@ -48,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 {
|
||||
std::string version_name;
|
||||
int version_code = 0;
|
||||
@@ -65,6 +83,8 @@ struct BuildProfile {
|
||||
// output-device virtual slots and restores them during controlled teardown;
|
||||
// executable guest code is never modified.
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
#define MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
|
||||
#include <elf.h>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
#if defined(__aarch64__)
|
||||
// Android ABI name as it appears in APK lib/ directories and build metadata.
|
||||
inline constexpr std::string_view kGuestAbi = "arm64-v8a";
|
||||
// APK split-config name carrying the guest native libraries.
|
||||
inline constexpr std::string_view kGuestApkSplitName = "config.arm64_v8a";
|
||||
// File name the payload store uses for the split APK.
|
||||
inline constexpr std::string_view kGuestSplitApkFile =
|
||||
"split_config.arm64_v8a.apk";
|
||||
// sha256 map key holding the split APK digest in payload metadata.
|
||||
inline constexpr std::string_view kGuestSplitApkHashKey = "arm64_v8a_split_apk";
|
||||
// Short CPU label for device metadata and user-facing strings.
|
||||
inline constexpr std::string_view kGuestCpuName = "aarch64";
|
||||
inline constexpr int kGuestElfMachine = EM_AARCH64;
|
||||
#elif defined(__x86_64__)
|
||||
inline constexpr std::string_view kGuestAbi = "x86_64";
|
||||
inline constexpr std::string_view kGuestApkSplitName = "config.x86_64";
|
||||
inline constexpr std::string_view kGuestSplitApkFile = "split_config.x86_64.apk";
|
||||
inline constexpr std::string_view kGuestSplitApkHashKey = "x86_64_split_apk";
|
||||
inline constexpr std::string_view kGuestCpuName = "x86_64";
|
||||
inline constexpr int kGuestElfMachine = EM_X86_64;
|
||||
#else
|
||||
#error "mocktail only supports aarch64 and x86_64 hosts"
|
||||
#endif
|
||||
|
||||
// APK zip entry holding the guest libroblox.so.
|
||||
inline constexpr std::string_view kGuestLibApkEntryPrefix = "lib/";
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
@@ -109,6 +109,15 @@ struct HostAbiProfile {
|
||||
size_t NativeMimallocConstructorRangeEndExclusive() const noexcept;
|
||||
bool ShouldInitializeNativeMimallocThreadAfterConstructor(
|
||||
size_t index) const noexcept;
|
||||
// True when both replays have the one verified shape. The native replay is
|
||||
// one range that runs to the end of .init_array. Its first four
|
||||
// constructors are the process-load constructor at the thread-initializer
|
||||
// checkpoint, the JNI singleton initializer, and two wiring entries. The
|
||||
// host-bridge replay runs the same constructors without the two wiring
|
||||
// entries. `jni_singleton_constructor` is the .init_array index of the JNI
|
||||
// singleton initializer.
|
||||
bool HasVerifiedConstructorPolicy(
|
||||
size_t jni_singleton_constructor) const noexcept;
|
||||
bool HasValidNativePreJniBootstrap() const noexcept;
|
||||
HostAllocatorStrategy ResolveAllocatorStrategy(
|
||||
bool host_bridges_override_present,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
|
||||
// stack slot, then reads the protocol from the returned holder:
|
||||
//
|
||||
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
|
||||
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
|
||||
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
|
||||
// ldr x8, [xN, #0x18]
|
||||
//
|
||||
// These functions find that call site in the initializer's first bytes and
|
||||
// check the getter's own code. Only relocated or build-varying fields (stack
|
||||
// slots, frame sizes, PC-relative offsets, the call target) are masked.
|
||||
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
|
||||
// 2.738.1397 and 2.740.931.
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
|
||||
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
|
||||
|
||||
namespace web_view_protocol_contract_detail {
|
||||
|
||||
inline bool MatchesMasked(std::string_view code, std::size_t offset,
|
||||
std::string_view pattern,
|
||||
std::string_view wildcard) {
|
||||
if (offset > code.size() || code.size() - offset < pattern.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t index = 0; index < pattern.size(); ++index) {
|
||||
if (wildcard[index] == 'x') continue;
|
||||
if (code[offset + index] != pattern[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// AArch64 instructions are little-endian words.
|
||||
inline std::uint32_t Word(std::string_view code, std::size_t index) {
|
||||
std::uint32_t word = 0;
|
||||
for (std::size_t byte = 0; byte < 4; ++byte) {
|
||||
word |= static_cast<std::uint32_t>(
|
||||
static_cast<unsigned char>(code[index * 4 + byte]))
|
||||
<< (8 * byte);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
// One AArch64 instruction word: bits outside `mask` may vary.
|
||||
struct MaskedWord {
|
||||
std::uint32_t mask;
|
||||
std::uint32_t value;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kExact = 0xffffffffU;
|
||||
// adrp Xd, page: immlo and immhi vary.
|
||||
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
|
||||
// add Xd, Xn, #imm12: imm12 varies.
|
||||
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
|
||||
|
||||
} // namespace web_view_protocol_contract_detail
|
||||
|
||||
// Returns the getter address called from the x86-64 initializer at
|
||||
// `address`, or nullopt unless the frame setup and exactly one call site
|
||||
// match.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::MatchesMasked;
|
||||
// push %rbp; mov %rsp,%rbp
|
||||
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
|
||||
return std::nullopt;
|
||||
}
|
||||
static constexpr std::string_view kCallSite(
|
||||
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
|
||||
"\x18",
|
||||
19);
|
||||
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
|
||||
static constexpr std::size_t kCallDisplacement = 8;
|
||||
const std::string_view window =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes);
|
||||
std::optional<std::size_t> call_site;
|
||||
for (std::size_t offset = 0; offset < window.size(); ++offset) {
|
||||
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
|
||||
if (call_site.has_value()) return std::nullopt;
|
||||
call_site = offset;
|
||||
}
|
||||
}
|
||||
if (!call_site.has_value()) return std::nullopt;
|
||||
std::int32_t displacement = 0;
|
||||
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
|
||||
sizeof(displacement));
|
||||
const std::uintptr_t next_instruction =
|
||||
address + *call_site + kCallDisplacement + sizeof(displacement);
|
||||
return next_instruction +
|
||||
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
|
||||
}
|
||||
|
||||
// The x86-64 getter stores the holder returned by the subsystem's acquire
|
||||
// into its result slot (%rdi) and returns that slot.
|
||||
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
|
||||
return web_view_protocol_contract_detail::MatchesMasked(
|
||||
code, 0,
|
||||
std::string_view(
|
||||
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
|
||||
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
|
||||
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
|
||||
kWebViewProtocolGetterBytesX86_64),
|
||||
"...........xxxx.......xxxx..................");
|
||||
}
|
||||
|
||||
// Returns the getter address called from the AArch64 initializer at
|
||||
// `address`, or nullopt unless exactly one call site matches.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::Word;
|
||||
const std::size_t words =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
|
||||
std::optional<std::uintptr_t> getter;
|
||||
for (std::size_t index = 0; index + 4 <= words; ++index) {
|
||||
const std::uint32_t slot_address = Word(code, index);
|
||||
const std::uint32_t call = Word(code, index + 1);
|
||||
const std::uint32_t handle_load = Word(code, index + 2);
|
||||
const std::uint32_t protocol_load = Word(code, index + 3);
|
||||
// add x8, sp, #slot
|
||||
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
|
||||
// bl getter
|
||||
if ((call & 0xfc000000U) != 0x94000000U) continue;
|
||||
// ldr xN, [sp, #slot]
|
||||
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
|
||||
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
|
||||
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
|
||||
// ldr x8, [xN, #0x18]
|
||||
const std::uint32_t handle_register = handle_load & 0x1fU;
|
||||
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
|
||||
if (getter.has_value()) return std::nullopt;
|
||||
// imm26 is a signed word offset from the bl instruction.
|
||||
const std::int64_t imm26 = call & 0x03ffffffU;
|
||||
const std::int64_t words_from_call =
|
||||
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
|
||||
getter = address + (index + 1) * 4 +
|
||||
static_cast<std::uintptr_t>(words_from_call * 4);
|
||||
}
|
||||
return getter;
|
||||
}
|
||||
|
||||
// The AArch64 getter saves its result slot (x8) and stores the holder
|
||||
// returned by the subsystem's acquire there.
|
||||
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
|
||||
using namespace web_view_protocol_contract_detail;
|
||||
static constexpr MaskedWord kGetter[] = {
|
||||
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
|
||||
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
|
||||
{kExact, 0x910003fdU}, // mov x29, sp
|
||||
{kAdrpMask, 0x90000009U}, // adrp x9, guard
|
||||
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
|
||||
{kExact, 0xaa0803f3U}, // mov x19, x8
|
||||
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
|
||||
{kExact, 0x36000109U}, // tbz w9, #0, initialize
|
||||
{kAdrpMask, 0x90000000U}, // adrp x0, holder
|
||||
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
|
||||
{kExact, 0x94000017U}, // bl acquire
|
||||
{kExact, 0xf9000260U}, // str x0, [x19]
|
||||
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
|
||||
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
|
||||
{kExact, 0xd65f03c0U}, // ret
|
||||
};
|
||||
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
|
||||
kWebViewProtocolGetterBytesArm64);
|
||||
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
|
||||
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
|
||||
++index) {
|
||||
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
@@ -48,9 +48,15 @@ struct PlatformIdentity {
|
||||
std::string manufacturer = "Mocktail";
|
||||
std::string model = "Mocktail Headless";
|
||||
std::string brand = "Mocktail";
|
||||
#if defined(__aarch64__)
|
||||
std::string device_code = "linux-arm64";
|
||||
std::string device_sku = "mocktail-arm64";
|
||||
std::string soc_model = "aarch64";
|
||||
#else
|
||||
std::string device_code = "linux-x86_64";
|
||||
std::string device_sku = "mocktail-x86_64";
|
||||
std::string soc_model = "x86_64";
|
||||
#endif
|
||||
};
|
||||
|
||||
struct RobloxCredentialView {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#if defined(__x86_64__)
|
||||
#include <xmmintrin.h>
|
||||
#endif
|
||||
#if defined(__aarch64__)
|
||||
#include <cstdint>
|
||||
#endif
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
@@ -22,16 +25,23 @@ class FmodThreadFloatingPointMode final {
|
||||
delete;
|
||||
|
||||
bool Enable(const char* thread_name) noexcept {
|
||||
#if defined(__x86_64__)
|
||||
#if defined(__x86_64__) || defined(__aarch64__)
|
||||
if (active_ || thread_name == nullptr) return false;
|
||||
const std::string_view name(thread_name);
|
||||
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
|
||||
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
|
||||
return false;
|
||||
}
|
||||
#if defined(__x86_64__)
|
||||
const unsigned int control = _mm_getcsr();
|
||||
original_mode_ = control & kModeMask;
|
||||
_mm_setcsr(control | kModeMask);
|
||||
#else
|
||||
std::uint64_t control;
|
||||
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
|
||||
original_mode_ = control & kModeMask;
|
||||
__asm__ volatile("msr fpcr, %0" ::"r"(control | kModeMask));
|
||||
#endif
|
||||
active_ = true;
|
||||
return true;
|
||||
#else
|
||||
@@ -41,11 +51,18 @@ class FmodThreadFloatingPointMode final {
|
||||
}
|
||||
|
||||
void Restore() noexcept {
|
||||
#if defined(__x86_64__)
|
||||
#if defined(__x86_64__) || defined(__aarch64__)
|
||||
if (active_) {
|
||||
// Restore only our two bits, preserving rounding, exception masks and
|
||||
// status flags set by the guest while the thread was attached.
|
||||
#if defined(__x86_64__)
|
||||
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
|
||||
#else
|
||||
std::uint64_t control;
|
||||
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
|
||||
__asm__ volatile("msr fpcr, %0" ::"r"((control & ~kModeMask) |
|
||||
original_mode_));
|
||||
#endif
|
||||
active_ = false;
|
||||
}
|
||||
#endif
|
||||
@@ -56,6 +73,13 @@ class FmodThreadFloatingPointMode final {
|
||||
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
|
||||
unsigned int original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#elif defined(__aarch64__)
|
||||
// FPCR FZ flushes single/double subnormals; FZ16 covers the half-precision
|
||||
// path used by some voice DSP filters.
|
||||
static constexpr std::uint64_t kModeMask =
|
||||
(std::uint64_t{1} << 24) | (std::uint64_t{1} << 19);
|
||||
std::uint64_t original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -61,8 +61,7 @@ class RobloxOutputDeviceBridge final {
|
||||
void SelectDevice(int index, bool input = false);
|
||||
void ConstructGuestString(void* destination, std::string_view value) const;
|
||||
|
||||
std::unique_ptr<NativeInputCapture> capture_;
|
||||
bool capture_profile_supported_ = false;
|
||||
std::shared_ptr<NativeInputCapture> capture_;
|
||||
std::array<std::uintptr_t, 6> original_capture_methods_{};
|
||||
mutable std::mutex mutex_;
|
||||
compat::FmodOutputDeviceBridgeProfile profile_;
|
||||
|
||||
@@ -16,6 +16,9 @@ struct AngleProbeOptions {
|
||||
bool allow_software_device = false;
|
||||
};
|
||||
|
||||
// Checks ANGLE entry points and client extensions without initializing a display.
|
||||
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options);
|
||||
|
||||
// Loads a pinned ANGLE pair, verifies the required EGL/GLES symbols, and
|
||||
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
|
||||
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
|
||||
|
||||
@@ -24,6 +24,35 @@ struct TraceArg {
|
||||
|
||||
// CLOCK_MONOTONIC in nanoseconds.
|
||||
std::uint64_t TraceClockNanos();
|
||||
// CPU time used so far by the calling thread, in nanoseconds.
|
||||
std::uint64_t TraceThreadCpuNanos();
|
||||
// CPU time used so far by every thread of the process, in nanoseconds.
|
||||
std::uint64_t TraceProcessCpuNanos();
|
||||
|
||||
// CPU time spent between two successive presents.
|
||||
struct FrameCpuSample {
|
||||
double process_ms = 0;
|
||||
// Set only when the previous present came from the same thread; thread CPU
|
||||
// clocks of different threads cannot be subtracted.
|
||||
bool has_thread = false;
|
||||
double thread_ms = 0;
|
||||
};
|
||||
|
||||
// Turns CPU clocks read at each present into per-frame CPU time.
|
||||
// Thread-safe.
|
||||
class FrameCpuSampler final {
|
||||
public:
|
||||
// Returns false for the first present, which ends no frame.
|
||||
bool Sample(std::uint64_t thread_id, std::uint64_t thread_cpu_ns,
|
||||
std::uint64_t process_cpu_ns, FrameCpuSample* sample);
|
||||
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
bool has_previous_ = false;
|
||||
std::uint64_t previous_thread_id_ = 0;
|
||||
std::uint64_t previous_thread_cpu_ns_ = 0;
|
||||
std::uint64_t previous_process_cpu_ns_ = 0;
|
||||
};
|
||||
|
||||
// Writes the Trace Event Format JSON array read by chrome://tracing and
|
||||
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
@@ -29,6 +30,36 @@ FilterPresentModes(PresentModePolicy policy,
|
||||
const char *PresentModePolicyName(PresentModePolicy policy);
|
||||
const char *PresentModeKhrName(VkPresentModeKHR mode);
|
||||
|
||||
// Creates the swapchain with the mode in `info`. A driver can reject a mode
|
||||
// that the device did not enable, so a failed create is retried once with the
|
||||
// mode the game asked for. `create` takes the info and returns a VkResult.
|
||||
template <typename Create>
|
||||
VkResult CreateSwapchainWithPresentModeFallback(VkSwapchainCreateInfoKHR &info,
|
||||
VkPresentModeKHR requested,
|
||||
Create create) {
|
||||
const VkResult result = create(info);
|
||||
if (result == VK_SUCCESS || info.presentMode == requested) {
|
||||
return result;
|
||||
}
|
||||
switch (result) {
|
||||
case VK_ERROR_OUT_OF_HOST_MEMORY:
|
||||
case VK_ERROR_OUT_OF_DEVICE_MEMORY:
|
||||
case VK_ERROR_DEVICE_LOST:
|
||||
case VK_ERROR_SURFACE_LOST_KHR:
|
||||
case VK_ERROR_NATIVE_WINDOW_IN_USE_KHR:
|
||||
return result; // Another present mode cannot fix these.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] swapchain presentMode %s failed (%d); retrying "
|
||||
"with %s\n",
|
||||
PresentModeKhrName(info.presentMode), static_cast<int>(result),
|
||||
PresentModeKhrName(requested));
|
||||
info.presentMode = requested;
|
||||
return create(info);
|
||||
}
|
||||
|
||||
// Extra images so acquire does not wait on the compositor-held buffer.
|
||||
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
|
||||
std::uint32_t requested,
|
||||
|
||||
@@ -35,6 +35,10 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
|
||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
std::uint32_t source_height, std::uint32_t width,
|
||||
std::uint32_t height, std::uint8_t* destination);
|
||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
std::uint32_t source_height, std::size_t source_stride_bytes,
|
||||
std::uint32_t width, std::uint32_t height,
|
||||
std::uint8_t* destination);
|
||||
|
||||
// Replacement textures keyed by the hash of a texture's level-0 compressed
|
||||
// bytes. An override lives at <override_dir>/<hash>.png. With a dump
|
||||
|
||||
@@ -30,6 +30,18 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent,
|
||||
std::uint32_t layer_count, VkDeviceSize* compressed,
|
||||
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
|
||||
// feature, from the MOCKTAIL_ADVERTISE_ETC2 value: "0" hides it, anything
|
||||
// else (or unset) reports it. Hidden ETC2 still decodes ETC2 images the
|
||||
@@ -47,6 +59,10 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
|
||||
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
|
||||
std::uint32_t SmallTextureUpscale(const char* value);
|
||||
|
||||
// Staging for decoded uploads is carved out of host-visible blocks this
|
||||
// large; an upload that needs more gets a block of its own.
|
||||
inline constexpr VkDeviceSize kEtc2StagingBlockBytes = 16 * 1024 * 1024;
|
||||
|
||||
// A timeline semaphore and the value an asynchronous decode will signal.
|
||||
// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
|
||||
// calling thread and the submit needs no extra wait.
|
||||
@@ -61,11 +77,12 @@ struct Etc2SubmitWait {
|
||||
// that is filled by decoding the application's staging bytes when the
|
||||
// command buffer is submitted. Every submit decodes again, as a native copy
|
||||
// re-reads its source on each execution.
|
||||
// Staging buffers live until their command buffer is begun, reset or freed.
|
||||
// Staging ranges live until their command buffer is begun, reset or freed.
|
||||
// MOCKTAIL_DISABLE_ETC2_EMULATION=1 turns the emulation off.
|
||||
class VulkanEtc2Emulation final {
|
||||
public:
|
||||
VulkanEtc2Emulation();
|
||||
explicit VulkanEtc2Emulation(
|
||||
VkDeviceSize staging_block_bytes = kEtc2StagingBlockBytes);
|
||||
~VulkanEtc2Emulation();
|
||||
VulkanEtc2Emulation(const VulkanEtc2Emulation&) = delete;
|
||||
VulkanEtc2Emulation& operator=(const VulkanEtc2Emulation&) = delete;
|
||||
@@ -147,6 +164,10 @@ class VulkanEtc2Emulation final {
|
||||
// pending, after waiting for any decode still writing into them.
|
||||
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
|
||||
|
||||
// Traced as the "emulated texture (MB)" and "compressed texture (MB)"
|
||||
// counters.
|
||||
EmulatedTextureBytes TextureBytes() const;
|
||||
|
||||
struct State;
|
||||
|
||||
private:
|
||||
|
||||
@@ -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,665 @@
|
||||
#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 <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#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 {
|
||||
// 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
|
||||
// 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);
|
||||
void NoteDeviceDestroyed(VkDevice device);
|
||||
void 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);
|
||||
|
||||
// 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;
|
||||
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:
|
||||
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;
|
||||
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 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 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();
|
||||
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 RecordFilteredEyesLocked(VkCommandBuffer command, bool smaa);
|
||||
// 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;
|
||||
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;
|
||||
};
|
||||
// vr.anti_aliasing. SMAA shares FXAA's final pass, targets and eye views.
|
||||
enum class AntiAliasing { kOff, kFxaa, kSmaa };
|
||||
AntiAliasing AntiAliasingMode() const;
|
||||
// Pipeline, targets and eye bindings for this frame; false means copy.
|
||||
bool PrepareFxaaLocked();
|
||||
bool CreateFxaaPipelineLocked(VkFormat target_format);
|
||||
bool CreateFullscreenPipelineLocked(VkShaderModule vertex,
|
||||
VkShaderModule fragment,
|
||||
VkPipelineLayout layout,
|
||||
VkRenderPass render_pass,
|
||||
VkPipeline* pipeline);
|
||||
void DestroyFxaaTargetsLocked();
|
||||
void ForgetFxaaSourceLocked(VkImage image);
|
||||
void DestroyFxaaLocked();
|
||||
// SMAA: edge detection and blending weights into per-eye images, then
|
||||
// neighborhood blending into FXAA's swapchain targets.
|
||||
struct SmaaPass {
|
||||
VkDescriptorSetLayout set_layout = VK_NULL_HANDLE;
|
||||
VkPipelineLayout layout = VK_NULL_HANDLE;
|
||||
VkRenderPass render_pass = VK_NULL_HANDLE;
|
||||
VkPipeline pipeline = VK_NULL_HANDLE;
|
||||
VkDescriptorSet sets[2] = {};
|
||||
};
|
||||
struct SmaaImage {
|
||||
VkImage image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkImageView view = VK_NULL_HANDLE;
|
||||
};
|
||||
struct SmaaState {
|
||||
std::vector<VkShaderModule> modules;
|
||||
VkDescriptorPool pool = VK_NULL_HANDLE;
|
||||
SmaaPass edges, weights, blend;
|
||||
SmaaImage area, search;
|
||||
// Per eye, at the eye's extent.
|
||||
SmaaImage edge_images[2], weight_images[2];
|
||||
VkFramebuffer edge_framebuffers[2] = {};
|
||||
VkFramebuffer weight_framebuffers[2] = {};
|
||||
std::uint32_t width = 0, height = 0;
|
||||
VkFormat target_format = VK_FORMAT_UNDEFINED;
|
||||
// The eye views the edge and blend sets were written with.
|
||||
VkImageView bound_sources[2] = {};
|
||||
bool failed = false;
|
||||
};
|
||||
bool PrepareSmaaLocked();
|
||||
bool CreateSmaaPipelinesLocked();
|
||||
bool CreateSmaaImageLocked(VkFormat format, std::uint32_t width,
|
||||
std::uint32_t height, VkImageUsageFlags usage,
|
||||
SmaaImage* out);
|
||||
bool UploadSmaaLookupLocked(SmaaImage* image, std::uint32_t width,
|
||||
std::uint32_t height, const void* bytes,
|
||||
std::size_t size);
|
||||
void DestroySmaaImageLocked(SmaaImage* image);
|
||||
void DestroySmaaTargetsLocked();
|
||||
void DestroySmaaLocked();
|
||||
// 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);
|
||||
// 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] = {};
|
||||
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;
|
||||
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_;
|
||||
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_{};
|
||||
SmaaState smaa_{};
|
||||
// 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;
|
||||
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,130 @@
|
||||
#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"
|
||||
|
||||
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 = void *(*)(void *result_buffer, void *device_object);
|
||||
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,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,102 @@
|
||||
#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,
|
||||
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,101 @@
|
||||
#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);
|
||||
|
||||
// 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];
|
||||
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,27 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
|
||||
#define MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// The rbxasset:// name of every regular file under <overlay_dir>/content, such
|
||||
// as rbxasset://textures/MouseLockedCursor.png. Sorted.
|
||||
std::vector<std::string> OverlayRbxAssetNames(
|
||||
const std::filesystem::path& overlay_dir);
|
||||
|
||||
// Roblox opens no local file for an rbxasset:// name it maps to a download.
|
||||
// Writes '!' over the first byte of each whole NUL-terminated copy of a name
|
||||
// in `data`, which unmaps it. Returns the number of copies changed.
|
||||
std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size,
|
||||
const std::vector<std::string>& names);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_COMMAND_LINE_H_
|
||||
#define MOCKTAIL_RUNTIME_COMMAND_LINE_H_
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -21,6 +22,7 @@ enum class WindowMode {
|
||||
struct CommandLineOptions {
|
||||
CommandMode mode = CommandMode::kRun;
|
||||
WindowMode window_mode = WindowMode::kUnspecified;
|
||||
std::optional<bool> vr_enabled;
|
||||
std::string program_name = "mocktail";
|
||||
std::string roblox_library_path;
|
||||
std::string graphics_backend;
|
||||
|
||||
@@ -18,6 +18,26 @@ std::string SelectVulkanIcdManifest(
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
std::string_view vendor);
|
||||
|
||||
// One GPU as the kernel reports it.
|
||||
struct HostGpu {
|
||||
unsigned int vendor = 0; // PCI vendor ID.
|
||||
std::string kernel_driver; // Bound kernel driver; empty when unknown.
|
||||
};
|
||||
|
||||
struct VulkanIcdSelection {
|
||||
std::string manifest; // Empty when no GPU has a usable driver.
|
||||
std::vector<std::string> skipped; // Why each passed-over GPU was skipped.
|
||||
};
|
||||
|
||||
// ICD manifest for the first GPU whose bound kernel driver has an installed
|
||||
// Vulkan driver. NVIDIA and AMD cards are tried before Intel when
|
||||
// `prefer_discrete` is set, and after it otherwise. A GPU with an unknown
|
||||
// kernel driver is matched by vendor alone.
|
||||
VulkanIcdSelection SelectHostVulkanIcd(
|
||||
const std::vector<HostGpu>& gpus,
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
bool prefer_discrete);
|
||||
|
||||
// Publishes the resolved graphics backend before the managed payload updater
|
||||
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
|
||||
// through ANGLE/Vulkan or accepts a window without a real graphics context.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#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 {
|
||||
|
||||
// Nightcap defaults this on and keeps it on only outside games; a user value
|
||||
// set in fflags.json or the overrides environment disables the switching.
|
||||
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 on the first notification; a byte that is not 0 or 1 is
|
||||
// left alone.
|
||||
class LuauCodegenMenuSwitch {
|
||||
public:
|
||||
using LocateFlagFn = std::function<std::uint8_t*()>;
|
||||
|
||||
LuauCodegenMenuSwitch(LocateFlagFn locate_flag,
|
||||
RobloxExperiencePresenceObserver next);
|
||||
|
||||
RobloxExperiencePresenceObserver observer();
|
||||
void OnPhase(RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request);
|
||||
|
||||
private:
|
||||
static void Notify(void* context, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request);
|
||||
|
||||
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,11 @@ bool MergeRuntimeClientSettingsOverrides(
|
||||
std::string_view base_json, std::string* merged_json, std::string* error,
|
||||
std::vector<std::string>* kept_overrides = nullptr);
|
||||
|
||||
// The resolved VR mode owns this flag in both client-settings channels.
|
||||
bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
|
||||
// answers microphone authorization from capture configuration. No permission
|
||||
// query opens a recording device.
|
||||
|
||||
@@ -47,8 +47,8 @@ struct RobloxThemeCacheResult {
|
||||
};
|
||||
|
||||
// Reads the current account's Roblox theme without changing appStorage. A
|
||||
// missing value leaves dark_theme empty so the caller can use Roblox's own
|
||||
// startup default.
|
||||
// missing or unrecognized value falls back to AuthenticatedTheme, then the
|
||||
// caller's startup default.
|
||||
RobloxThemeCacheResult ReadRobloxThemeCache(
|
||||
const std::filesystem::path& app_storage_file,
|
||||
std::int64_t authenticated_user_id);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Exact JNI entrypoints behind the APK AccountProtocol device-integrity
|
||||
// handlers: JNIAccountProtocol name getters and MessageBus raw handlers.
|
||||
struct RobloxAccountProtocolSymbols {
|
||||
using GetStringFn = jstring (*)(JNIEnv*, jclass);
|
||||
|
||||
GetStringFn get_protocol_name = nullptr;
|
||||
GetStringFn get_device_integrity_available_method_name = nullptr;
|
||||
GetStringFn get_integrity_token_method_name = nullptr;
|
||||
GetStringFn get_support_key = nullptr;
|
||||
GetStringFn get_token_key = nullptr;
|
||||
GetStringFn get_result_key = nullptr;
|
||||
void (*set_request_handler_raw)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_request_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_protocol_name && get_device_integrity_available_method_name &&
|
||||
get_integrity_token_method_name && get_support_key &&
|
||||
get_token_key && get_result_key && set_request_handler_raw &&
|
||||
clear_request_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxAccountProtocolObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_request_handler)(void*, std::shared_ptr<void>,
|
||||
std::string (*)(void*, JNIEnv*,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_request_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_request_handler &&
|
||||
clear_request_handler;
|
||||
}
|
||||
};
|
||||
|
||||
// Answers AccountProtocol device-integrity requests for a host that cannot
|
||||
// produce Play Integrity tokens. No integrity token is ever produced.
|
||||
//
|
||||
// DeviceIntegrityAvailable reports no support. The APK handler always reports
|
||||
// support, so this answer differs from Android: it tells Roblox's challenge
|
||||
// flow up front that no token can come, instead of letting it request one.
|
||||
//
|
||||
// GetIntegrityToken returns at once with an empty token and
|
||||
// TOKEN_PROVIDER_UNINITIALIZED, the APK's answer when its Play token provider
|
||||
// never initialized. The APK answers INVALID_NONCE for a request without a
|
||||
// requestHash; this bridge never inspects the request, so it cannot.
|
||||
// LuaApp bounds each token request with its
|
||||
// DeviceIntegrityNativeTimeoutMilliseconds flag; an immediate answer keeps a
|
||||
// login challenge from sitting out that bound.
|
||||
class RobloxAccountBridge final {
|
||||
public:
|
||||
RobloxAccountBridge(JniEnvironmentProvider environment,
|
||||
RobloxAccountProtocolSymbols symbols,
|
||||
RobloxAccountProtocolObjects objects);
|
||||
~RobloxAccountBridge();
|
||||
RobloxAccountBridge(const RobloxAccountBridge&) = delete;
|
||||
RobloxAccountBridge& operator=(const RobloxAccountBridge&) = delete;
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxAccountProtocolSymbols symbols_;
|
||||
const RobloxAccountProtocolObjects objects_;
|
||||
// Serializes lifecycle only. Native dispatch never holds this mutex.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
|
||||
|
||||
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// In-experience CoreScripts query the app's cross-experience call state before
|
||||
// initializing voice. Mocktail does not start or accept cross-experience calls,
|
||||
// so that state is Idle. This query neither joins voice nor opens a microphone.
|
||||
class RobloxCallProtocolBridge final {
|
||||
public:
|
||||
RobloxCallProtocolBridge(JniEnvironmentProvider environment,
|
||||
RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects);
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
RobloxMessageBusRequestBridge bridge_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
#endif
|
||||
@@ -38,6 +38,16 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
// 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 mocktail
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
@@ -14,9 +15,12 @@
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/owned_pthread.h"
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_call_protocol_bridge.h"
|
||||
#include "runtime/roblox_experience_launch_bridge.h"
|
||||
#include "runtime/roblox_experience_presence.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_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
#include "runtime/webview_roblox_cookie.h"
|
||||
@@ -95,7 +99,9 @@ struct RobloxExperienceSurfaceProvider {
|
||||
void* context = nullptr;
|
||||
GameSurface (*snapshot)(void* context) = nullptr;
|
||||
|
||||
bool valid() const { return snapshot != nullptr; }
|
||||
bool valid() const {
|
||||
return snapshot != nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
// Production composition for the APK platform protocols and ExperienceProtocol
|
||||
@@ -120,7 +126,8 @@ class RobloxExperienceComposition final {
|
||||
RobloxExperienceSurfaceProvider surface_provider = {},
|
||||
RobloxExperiencePresenceObserver presence_observer = {},
|
||||
bool clear_persisted_web_view_cookie = false,
|
||||
bool microphone_enabled = false);
|
||||
bool microphone_enabled = false,
|
||||
RobloxAccountProtocolSymbols account_symbols = {});
|
||||
~RobloxExperienceComposition();
|
||||
|
||||
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
|
||||
@@ -162,6 +169,7 @@ class RobloxExperienceComposition final {
|
||||
bool visible = true;
|
||||
bool back_navigation_disabled = false;
|
||||
bool show_domain_as_title = false;
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
static Status DispatchLaunch(void* context,
|
||||
@@ -201,8 +209,11 @@ class RobloxExperienceComposition final {
|
||||
const WebSurfacePresentation& presentation);
|
||||
// WebViewProtocol and BrowserService are two logical transports over the
|
||||
// APK's single physical WebView container. Either protocol may close or
|
||||
// mutate the surface that the other one opened.
|
||||
Status CloseWebSurface();
|
||||
// mutate the surface that the other one opened. A nonzero
|
||||
// expected_logical_generation closes only that surface.
|
||||
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
|
||||
// Closes a solved challenge that Roblox left open past its grace period.
|
||||
Status CloseUnclaimedSolvedChallenge();
|
||||
void HandleWebSurfaceExit(uint64_t process_generation);
|
||||
Status Dispatch(const RobloxExperienceLaunchRequest& request);
|
||||
Status PromoteAuthenticatedSession();
|
||||
@@ -229,6 +240,7 @@ class RobloxExperienceComposition final {
|
||||
const RobloxWebViewMessageBusSymbols web_view_symbols_;
|
||||
const RobloxBrowserServiceSymbols browser_service_symbols_;
|
||||
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
|
||||
const RobloxAccountProtocolSymbols account_symbols_;
|
||||
const bool microphone_enabled_;
|
||||
const RobloxGameSessionSymbols game_symbols_;
|
||||
const RobloxExperienceJniFactory jni_factory_;
|
||||
@@ -248,6 +260,10 @@ class RobloxExperienceComposition final {
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
|
||||
std::unique_ptr<RobloxHapticProtocolBridge> haptic_protocol_bridge_;
|
||||
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
|
||||
std::shared_ptr<LifecycleTarget> lifecycle_target_;
|
||||
@@ -257,6 +273,13 @@ class RobloxExperienceComposition final {
|
||||
uint64_t web_surface_logical_generation_ = 0;
|
||||
uint64_t next_web_surface_process_generation_ = 1;
|
||||
uint64_t next_web_surface_logical_generation_ = 1;
|
||||
// The current surface closes as soon as its solved challenge is forwarded.
|
||||
bool web_surface_close_when_challenge_solved_ = false;
|
||||
// Set when a solved challenge is forwarded; applies only to that logical
|
||||
// surface generation.
|
||||
std::optional<std::chrono::steady_clock::time_point>
|
||||
solved_challenge_close_deadline_;
|
||||
uint64_t solved_challenge_logical_generation_ = 0;
|
||||
SecureWebViewRobloxCookie web_view_cookie_;
|
||||
std::string web_view_cookie_initialization_error_;
|
||||
bool web_view_cookie_synchronized_ = false;
|
||||
|
||||
@@ -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
|
||||
@@ -40,9 +40,10 @@ struct AndroidKeyMapping {
|
||||
// through with an ABI-incompatible numeric value.
|
||||
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
|
||||
|
||||
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
|
||||
// numbers are not numerically compatible with Android button bit values.
|
||||
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
|
||||
// Translates SDL button numbers to the nativePassMouseButton argument.
|
||||
// The first three are Roblox's zero-based MouseButton1/2/3 indices, not Android
|
||||
// MotionEvent button bits.
|
||||
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button);
|
||||
|
||||
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
|
||||
int32_t RobloxGamepadType(platform::GamepadFamily family);
|
||||
|
||||
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
|
||||
Status ParseRobloxLaunchUri(std::string_view uri,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
// Converts a WebView launchGame command
|
||||
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
|
||||
// contract. Returns kUnsupported for other commands and kInvalidArgument for
|
||||
// a malformed launchGame request.
|
||||
Status ParseRobloxWebViewLaunchGame(std::string_view command,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
struct RobloxMessageBusSymbols {
|
||||
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
|
||||
jboolean) = nullptr;
|
||||
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
|
||||
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
|
||||
jstring) = nullptr;
|
||||
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return subscribe_request && delete_connection && publish_response &&
|
||||
set_async_handler && clear_handler && call_response_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxMessageBusObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring)) = nullptr;
|
||||
void (*clear_raw_callback)(void*, jobject) = nullptr;
|
||||
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_async_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_raw_callback &&
|
||||
clear_raw_callback && create_async_handler && clear_async_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxMessageBusReply {
|
||||
std::string json;
|
||||
jint code = 0;
|
||||
const char* outcome = "ok";
|
||||
};
|
||||
|
||||
// Owns both legacy subscriptions and correlated async request handlers.
|
||||
// A policy receives only a request's method index and JNI-local payload.
|
||||
class RobloxMessageBusRequestBridge final {
|
||||
public:
|
||||
using Handler =
|
||||
std::function<RobloxMessageBusReply(JNIEnv*, jstring, std::size_t)>;
|
||||
RobloxMessageBusRequestBridge(JniEnvironmentProvider environment,
|
||||
RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects,
|
||||
std::string protocol,
|
||||
std::vector<std::string> methods,
|
||||
Handler handler);
|
||||
~RobloxMessageBusRequestBridge();
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxMessageBusSymbols symbols_;
|
||||
const RobloxMessageBusObjects objects_;
|
||||
const std::string protocol_name_;
|
||||
const std::vector<std::string> methods_;
|
||||
const Handler handler_;
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
#endif
|
||||
@@ -1,58 +1,12 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
using RobloxPermissionsMessageBusSymbols = RobloxMessageBusSymbols;
|
||||
using RobloxPermissionsMessageBusObjects = RobloxMessageBusObjects;
|
||||
|
||||
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
|
||||
struct RobloxPermissionsMessageBusSymbols {
|
||||
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
|
||||
jboolean) = nullptr;
|
||||
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
|
||||
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
|
||||
jstring) = nullptr;
|
||||
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return subscribe_request && delete_connection && publish_response &&
|
||||
set_async_handler && clear_handler && call_response_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxPermissionsMessageBusObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring)) = nullptr;
|
||||
void (*clear_raw_callback)(void*, jobject) = nullptr;
|
||||
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_async_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_raw_callback &&
|
||||
clear_raw_callback && create_async_handler && clear_async_handler;
|
||||
}
|
||||
};
|
||||
|
||||
// Installs both legacy protocol subscriptions and correlated async handlers
|
||||
// before native bootstrap. MICROPHONE_ACCESS follows host capture
|
||||
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
|
||||
// Android APK. No query/request opens an audio device, enables a disabled
|
||||
// microphone, or changes Roblox account eligibility. Other capabilities remain
|
||||
// unsupported.
|
||||
class RobloxPermissionsBridge final {
|
||||
public:
|
||||
RobloxPermissionsBridge(JniEnvironmentProvider environment,
|
||||
@@ -60,26 +14,12 @@ class RobloxPermissionsBridge final {
|
||||
RobloxPermissionsMessageBusObjects objects,
|
||||
bool microphone_enabled);
|
||||
~RobloxPermissionsBridge();
|
||||
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
|
||||
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxPermissionsMessageBusSymbols symbols_;
|
||||
const RobloxPermissionsMessageBusObjects objects_;
|
||||
const bool microphone_enabled_;
|
||||
// Serializes lifecycle only. Native dispatch never holds this mutex.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
@@ -15,6 +16,8 @@ struct RobloxPlatformWebSymbols {
|
||||
RobloxWebViewMessageBusSymbols web_view;
|
||||
RobloxBrowserServiceSymbols browser_service;
|
||||
RobloxPermissionsMessageBusSymbols permissions;
|
||||
// Optional and excluded from complete(); see RobloxExperienceComposition.
|
||||
RobloxAccountProtocolSymbols account;
|
||||
|
||||
bool complete() const {
|
||||
return web_view.complete() && browser_service.complete() &&
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
struct ResolvedFollowUserPlace {
|
||||
int64_t place_id = 0;
|
||||
std::string game_instance_id; // Roblox's "gameId" for the running session.
|
||||
};
|
||||
|
||||
// Reads the session cookie mocktail already persists at
|
||||
// ~/.local/share/mocktail/auth/roblox.cookie (or $XDG_DATA_HOME
|
||||
// equivalent). Returns an empty string if no session is stored.
|
||||
std::string ReadStoredRoblosecurityCookie();
|
||||
|
||||
// Calls Roblox's presence API to find what place/instance a user is
|
||||
// currently in. `auth_cookie` is the runtime's already-authenticated
|
||||
// .ROBLOSECURITY (or equivalent bearer token) — this function performs no
|
||||
// authentication of its own and requires the runtime's validated session.
|
||||
//
|
||||
// Returns an error if the user is offline, has presence hidden, or is not
|
||||
// in a joinable experience (e.g. in Studio, or privacy settings block it).
|
||||
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
|
||||
ResolvedFollowUserPlace* out);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
|
||||
@@ -89,6 +89,12 @@ struct RobloxTextDisplaySink {
|
||||
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 {
|
||||
using SyncFn = Status (*)(void* context, const char* utf8, std::size_t size,
|
||||
int32_t cursor_utf16);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -10,8 +11,10 @@ namespace runtime {
|
||||
// Android's RbxKeyboard resolves the Roblox Font enum through
|
||||
// assets/android/fonts/font-mappings.json. The selected file lives below
|
||||
// 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 {
|
||||
std::string primary_file;
|
||||
std::vector<std::string> emoji_files;
|
||||
float size_scale = 0.795F;
|
||||
bool exact_mapping = false;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
@@ -30,6 +32,9 @@ inline constexpr char kRobloxOpenCaptchaViewNotification[] =
|
||||
"OPEN_CAPTCHA_VIEW";
|
||||
inline constexpr char kRobloxViewProfileNotification[] = "VIEW_PROFILE";
|
||||
inline constexpr char kRobloxPurchaseRobuxNotification[] = "PURCHASE_ROBUX";
|
||||
// Sent with a decimal user id when the app asks for a chat with that user.
|
||||
inline constexpr char kRobloxLaunchConversationNotification[] =
|
||||
"LAUNCH_CONVERSATION";
|
||||
|
||||
// Owned subset of WebViewProtocol's openWindow payload. The URL is copied
|
||||
// before the MessageBus RawCallback returns, so no JNI local reference escapes.
|
||||
@@ -42,6 +47,10 @@ struct RobloxWebViewOpenRequest {
|
||||
std::optional<bool> show_domain_as_title;
|
||||
std::optional<bool> back_button_visible;
|
||||
std::optional<bool> hide_header;
|
||||
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
|
||||
// window once its solved captcha has been forwarded, as Android's captcha
|
||||
// activity finishes itself on success.
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
|
||||
@@ -61,6 +70,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
|
||||
RobloxWebViewMutationRequest* request);
|
||||
Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
// True for a page's Navigation.navigateToFeature command reporting a solved
|
||||
// verification (see webview::IsRobloxChallengeSolvedFeature).
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
|
||||
@@ -106,8 +118,43 @@ using PublishWebViewRawFn = void (*)(JNIEnv *, jobject, jstring, jstring);
|
||||
using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
||||
jstring, jstring);
|
||||
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
||||
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnGameLoaded(namespace,
|
||||
// detail, detailType).
|
||||
using SendRobloxAppEventOnGameLoadedFn = void (*)(JNIEnv*, jclass, jstring,
|
||||
jstring, jstring);
|
||||
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnAppReady(namespace, detail,
|
||||
// detailType, appName). The engine holds the event until appName is ready.
|
||||
using SendRobloxAppEventOnAppReadyFn = void (*)(JNIEnv*, jclass, jstring,
|
||||
jstring, jstring, jstring);
|
||||
using UpdateRobloxCookieSetHandlerFn =
|
||||
void (*)(JNIEnv*, jobject, jobject);
|
||||
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
||||
// and its handleJavascriptCallback subscriber, exist only while a handle is
|
||||
// held. Roblox's handle type is not trivially destructible, so its getter
|
||||
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
|
||||
// AArch64). The user-provided destructor gives this type the same calling
|
||||
// convention. It does not release the handle.
|
||||
class RobloxWebViewProtocolHandle final {
|
||||
public:
|
||||
RobloxWebViewProtocolHandle() = default;
|
||||
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
|
||||
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
|
||||
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
|
||||
default;
|
||||
~RobloxWebViewProtocolHandle() {}
|
||||
|
||||
void* holder() const { return holder_; }
|
||||
|
||||
private:
|
||||
void* holder_ = nullptr;
|
||||
};
|
||||
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
|
||||
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
|
||||
// resolver finds it through the first call in
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol.
|
||||
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
|
||||
|
||||
struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
|
||||
@@ -129,6 +176,11 @@ struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
|
||||
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
|
||||
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
||||
// Optional: found by code contract, not by symbol name.
|
||||
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
||||
// Optional: without them LAUNCH_CONVERSATION is reported as unavailable.
|
||||
SendRobloxAppEventOnGameLoadedFn send_app_event_on_game_loaded = nullptr;
|
||||
SendRobloxAppEventOnAppReadyFn send_app_event_on_app_ready = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_open_window_id != nullptr &&
|
||||
@@ -232,11 +284,14 @@ private:
|
||||
kDataModelUnfocused,
|
||||
kDataModelFocused,
|
||||
kMessageBusWindowClose,
|
||||
kStartConversationWithUser,
|
||||
};
|
||||
|
||||
struct PendingHostWindowEvent {
|
||||
HostWindowEventType type = HostWindowEventType::kDataModelUnfocused;
|
||||
uint64_t generation = 0;
|
||||
// Decimal user id of a kStartConversationWithUser event.
|
||||
std::string user_id;
|
||||
};
|
||||
|
||||
Status CheckJniException(JNIEnv *env, const char *operation) const;
|
||||
@@ -245,6 +300,8 @@ private:
|
||||
Status DispatchOpenRequest(RobloxWebViewOpenRequest request,
|
||||
OpenSource source);
|
||||
void QueueHostWindowEvent(HostWindowEventType type, uint64_t generation);
|
||||
void QueueStartConversation(std::string user_id);
|
||||
Status SendStartConversationEvents(JNIEnv* env, const std::string& user_id);
|
||||
Status BeginDispatch();
|
||||
void EndDispatch();
|
||||
static void RawMessageCallback(void *context, JNIEnv *env, jstring message);
|
||||
@@ -287,6 +344,10 @@ private:
|
||||
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
|
||||
raw_callback_targets_{};
|
||||
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
|
||||
// The reference this counts is kept for the process lifetime, as Roblox's
|
||||
// Android app start keeps its own. Neither Shutdown nor destruction
|
||||
// releases it.
|
||||
RobloxWebViewProtocolHandle web_view_protocol_handle_;
|
||||
std::size_t in_flight_dispatches_ = 0;
|
||||
bool initializing_ = false;
|
||||
bool shutting_down_ = false;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_input_native_adapter.h"
|
||||
#include "runtime/roblox_text_surface_overlay.h"
|
||||
#include "runtime/roblox_xr_controller_input.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -35,20 +36,40 @@ class RobloxWindowInputRuntime final {
|
||||
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
|
||||
Status UpdateTextFocusProperties(uint64_t generation,
|
||||
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;
|
||||
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:
|
||||
static void PlatformEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
static bool MouseLockQueryCallback(void* context, bool* locked_center);
|
||||
static void GamepadEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
static void XrControllerEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
|
||||
RobloxTextSurfaceOverlay text_surface_overlay_;
|
||||
std::unique_ptr<platform::TextClipboard> text_clipboard_;
|
||||
RobloxInputRuntime runtime_;
|
||||
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 mouse_lock_query_registered_ = 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 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 {
|
||||
kAuto,
|
||||
kSystem,
|
||||
@@ -96,6 +105,10 @@ class RuntimeConfig {
|
||||
static RuntimeConfig FromEnvironment(const Environment& environment);
|
||||
|
||||
bool headless() const { return headless_; }
|
||||
bool vr_enabled() const { return vr_enabled_; }
|
||||
// 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 {
|
||||
return roblox_library_path_;
|
||||
}
|
||||
@@ -121,6 +134,15 @@ class RuntimeConfig {
|
||||
return roblox_http_user_agent_;
|
||||
}
|
||||
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 PerformancePolicy& performance() const { return performance_; }
|
||||
const std::string& audio_output_device() const {
|
||||
@@ -161,6 +183,9 @@ class RuntimeConfig {
|
||||
|
||||
private:
|
||||
bool headless_ = false;
|
||||
bool vr_enabled_ = kDefaultVrEnabled;
|
||||
VrMode vr_mode_ = kDefaultVrEnabled ? VrMode::kOn : VrMode::kOff;
|
||||
bool vr_valid_ = true;
|
||||
std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so";
|
||||
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
|
||||
std::string graphics_backend_name_ = "direct-vulkan";
|
||||
@@ -171,6 +196,8 @@ class RuntimeConfig {
|
||||
bool device_profile_valid_ = true;
|
||||
std::optional<std::string> roblox_http_user_agent_;
|
||||
FrameRatePolicy frame_rate_;
|
||||
FrameRatePolicy vr_frame_rate_{FrameRateLimitMode::kUnlimited, 0};
|
||||
std::string vr_anti_aliasing_ = "fxaa";
|
||||
std::string vsync_mode_ = "auto";
|
||||
PerformancePolicy performance_;
|
||||
std::string audio_output_device_ = "default";
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ActivePayloadPaths {
|
||||
std::filesystem::path root;
|
||||
std::filesystem::path roblox_library;
|
||||
std::filesystem::path base_apk;
|
||||
std::filesystem::path x86_64_split_apk;
|
||||
std::filesystem::path arch_split_apk;
|
||||
std::filesystem::path assets_content;
|
||||
std::filesystem::path compatibility_manifest;
|
||||
std::filesystem::path host_abi_profile;
|
||||
@@ -67,6 +67,9 @@ class RuntimePaths {
|
||||
return active_payload_manifest_;
|
||||
}
|
||||
const std::filesystem::path& cookie_file() const { return cookie_file_; }
|
||||
const std::filesystem::path& asset_overlay_dir() const {
|
||||
return asset_overlay_dir_;
|
||||
}
|
||||
bool use_real_sober_paths() const { return use_real_sober_paths_; }
|
||||
|
||||
std::filesystem::path DefaultAssetPath() const;
|
||||
@@ -99,6 +102,7 @@ class RuntimePaths {
|
||||
std::filesystem::path config_file_;
|
||||
std::filesystem::path active_payload_manifest_;
|
||||
std::filesystem::path cookie_file_;
|
||||
std::filesystem::path asset_overlay_dir_;
|
||||
bool use_real_sober_paths_ = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ struct HostAbiDerivationOptions {
|
||||
std::filesystem::path reference_library;
|
||||
std::filesystem::path reference_profile;
|
||||
std::vector<std::filesystem::path> reference_compatibility_manifests;
|
||||
// Optional libroblox.so of another build that the compatibility manifests
|
||||
// describe. It supplies the FMOD output-device anchors when the manifests
|
||||
// have none for the reference build.
|
||||
std::filesystem::path runtime_fallback_library;
|
||||
std::filesystem::path candidate_payload_directory;
|
||||
std::filesystem::path output_directory;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -33,11 +34,18 @@ struct PayloadGarbageResult {
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// Optional per-store fault hook for atomic manifest writes. Returning true
|
||||
// makes the selected operation fail through its normal cleanup path.
|
||||
enum class ManifestWritePhase { kCreate, kWrite, kFileFsync, kRename };
|
||||
using ManifestWriteFault =
|
||||
std::function<bool(const std::filesystem::path&, ManifestWritePhase)>;
|
||||
|
||||
class PayloadStore final {
|
||||
public:
|
||||
PayloadStore(std::filesystem::path root,
|
||||
std::filesystem::path compatibility_manifest,
|
||||
std::filesystem::path runtime_binary = {});
|
||||
std::filesystem::path runtime_binary = {},
|
||||
ManifestWriteFault manifest_write_fault = {});
|
||||
|
||||
PayloadStoreResult Stage(const std::filesystem::path& prepared_payload);
|
||||
PayloadStoreResult Promote(std::string_view payload_id);
|
||||
@@ -60,6 +68,7 @@ class PayloadStore final {
|
||||
std::filesystem::path root_;
|
||||
std::filesystem::path compatibility_manifest_;
|
||||
std::filesystem::path runtime_binary_;
|
||||
ManifestWriteFault manifest_write_fault_;
|
||||
};
|
||||
|
||||
} // namespace mocktail::update
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
||||
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
||||
|
||||
#include <spawn.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -22,6 +25,12 @@ std::string_view CanaryGraphicsBackendName(CanaryGraphicsBackend backend);
|
||||
bool ParseCanaryGraphicsBackend(std::string_view name,
|
||||
CanaryGraphicsBackend* backend);
|
||||
|
||||
// Optional per-request replacement for the POSIX spawn operation.
|
||||
// It follows posix_spawn return/ownership semantics; empty uses posix_spawn.
|
||||
using CanarySpawn =
|
||||
std::function<int(pid_t*, const char*, const posix_spawn_file_actions_t*,
|
||||
const posix_spawnattr_t*, char* const[], char* const[])>;
|
||||
|
||||
struct CanaryOptions {
|
||||
std::filesystem::path runtime_binary;
|
||||
std::filesystem::path payload_directory;
|
||||
@@ -29,9 +38,9 @@ struct CanaryOptions {
|
||||
std::filesystem::path host_abi_profile;
|
||||
std::filesystem::path cache_root;
|
||||
std::filesystem::path state_root;
|
||||
CanaryGraphicsBackend graphics_backend =
|
||||
CanaryGraphicsBackend::kDirectVulkan;
|
||||
CanaryGraphicsBackend graphics_backend = CanaryGraphicsBackend::kDirectVulkan;
|
||||
int timeout_seconds = 150;
|
||||
CanarySpawn spawn;
|
||||
|
||||
bool probation_candidate() const { return !host_abi_profile.empty(); }
|
||||
};
|
||||
@@ -39,6 +48,8 @@ struct CanaryOptions {
|
||||
struct CanaryResult {
|
||||
std::filesystem::path log_path;
|
||||
int exit_code = -1;
|
||||
// Direct posix_spawn return code; zero when spawn was not attempted.
|
||||
int spawn_error = 0;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const {
|
||||
|
||||
@@ -26,6 +26,8 @@ struct UpdatePaths {
|
||||
// build the single file happens to describe.
|
||||
std::filesystem::path host_abi_reference_profile;
|
||||
std::filesystem::path runtime_binary;
|
||||
// Optional packaging wrapper; runtime_binary identifies the actual ELF.
|
||||
std::filesystem::path runtime_launcher;
|
||||
};
|
||||
|
||||
struct UpdateRequest {
|
||||
@@ -38,6 +40,7 @@ struct UpdateRequest {
|
||||
CanaryGraphicsBackend canary_graphics_backend =
|
||||
CanaryGraphicsBackend::kDirectVulkan;
|
||||
int progress_fd = -1;
|
||||
CanarySpawn canary_spawn;
|
||||
};
|
||||
|
||||
struct UpdateResult {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::webview {
|
||||
|
||||
// Navigation.navigateToFeature features a Roblox verification page sends when
|
||||
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
|
||||
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
|
||||
// helper and the runtime both classify page events with this list.
|
||||
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
|
||||
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
|
||||
feature == "ChallengeCompleted";
|
||||
}
|
||||
|
||||
} // namespace mocktail::webview
|
||||
|
||||
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
@@ -13,6 +13,11 @@ pre-commit:
|
||||
desktop-entry:
|
||||
glob: "*.desktop"
|
||||
run: desktop-file-validate {staged_files}
|
||||
changelog-width:
|
||||
glob: "CHANGELOG.md"
|
||||
run: >-
|
||||
git diff --cached -U0 -- CHANGELOG.md |
|
||||
awk '/^\+[^+]/ && length($0) > 111 { print "CHANGELOG.md: added line over 110 characters: " substr($0, 2); bad = 1 } END { exit bad }'
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Allowed reduced aarch64 packages from pkgforge-dev/archlinux-pkgs-debloated.
|
||||
# The installer verifies the rolling release asset IDs and SHA256 digests.
|
||||
ffmpeg-mini-aarch64.pkg.tar.zst
|
||||
gdk-pixbuf2-mini-aarch64.pkg.tar.xz
|
||||
gtk4-mini-aarch64.pkg.tar.zst
|
||||
icu-mini-aarch64.pkg.tar.zst
|
||||
librsvg-mini-aarch64.pkg.tar.zst
|
||||
libxml2-mini-aarch64.pkg.tar.zst
|
||||
mesa-mini-aarch64.pkg.tar.zst
|
||||
opus-mini-aarch64.pkg.tar.zst
|
||||
@@ -7,6 +7,7 @@ MOCKTAIL_PORTABLE_MODE=standalone
|
||||
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
|
||||
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
|
||||
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
|
||||
MOCKTAIL_UPDATE_RUNTIME_BINARY=${SHARUN_DIR}/shared/bin/mocktail
|
||||
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
|
||||
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
|
||||
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: vii <coderdayton14@gmail.com>
|
||||
|
||||
pkgname=nightcap
|
||||
pkgver=0.3.1
|
||||
pkgver=0.3.4
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = nightcap-bin
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
||||
pkgver = 0.3.1
|
||||
pkgver = 0.3.4
|
||||
pkgrel = 1
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
@@ -34,10 +34,10 @@ pkgbase = nightcap-bin
|
||||
conflicts = mocktail
|
||||
conflicts = nightcap
|
||||
conflicts = nightcap-git
|
||||
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
|
||||
noextract = nightcap-0.3.4-1-x86_64.pkg.tar.zst
|
||||
options = !debug
|
||||
options = !strip
|
||||
source = nightcap-0.3.1-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.1/nightcap-0.3.1-1-x86_64.pkg.tar.zst
|
||||
source = nightcap-0.3.4-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.4/nightcap-0.3.4-1-x86_64.pkg.tar.zst
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = nightcap-bin
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=nightcap-bin
|
||||
pkgver=0.3.1
|
||||
pkgver=0.3.4
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = nightcap
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 0.3.1
|
||||
pkgver = 0.3.4
|
||||
pkgrel = 1
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
@@ -42,7 +42,7 @@ pkgbase = nightcap
|
||||
conflicts = nightcap-git
|
||||
options = !debug
|
||||
options = !lto
|
||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
|
||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.4
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = nightcap
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=nightcap
|
||||
pkgver=0.3.1
|
||||
pkgver=0.3.4
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
// cc -O2 -o mocktail_webview_host mocktail_webview_host.c $(pkg-config --cflags --libs gtk4 webkitgtk-6.0)
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <glib-unix.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <signal.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <webkit/webkit.h>
|
||||
|
||||
#define PORT 2137
|
||||
#define MAX_PACKET (64u * 1024u + 12u)
|
||||
#define VERSION "2.725.1142"
|
||||
|
||||
enum {
|
||||
OP_LOAD_URL = 1, OP_SET_TITLE = 2, OP_SET_VISIBLE = 3, OP_EVAL_JS = 4,
|
||||
OP_CLOSE = 5, OP_SET_BACK_DISABLED = 6, OP_SHOW_DOMAIN_TITLE = 7,
|
||||
OP_SET_COOKIE = 8, OP_CLEAR_COOKIE = 9, OP_RETAIN_COOKIE = 10
|
||||
};
|
||||
|
||||
enum { EV_EXECUTE_ROBLOX = 1, EV_ROBLOX_WK_HYBRID = 2, EV_READY = 3, EV_COOKIE = 4 };
|
||||
|
||||
static const char kBridge[] =
|
||||
"(() => { 'use strict';"
|
||||
" const h = window.webkit && window.webkit.messageHandlers;"
|
||||
" const handler = h && h.executeRoblox; if (!handler) return;"
|
||||
" const bridge = {};"
|
||||
" Object.defineProperty(bridge, 'executeRoblox', {"
|
||||
" value: (q) => handler.postMessage(JSON.parse(q)),"
|
||||
" enumerable: true, writable: false, configurable: false });"
|
||||
" try { Object.defineProperty(window, '__globalRobloxAndroidBridge__', {"
|
||||
" value: bridge, enumerable: true, writable: false, configurable: false });"
|
||||
" } catch (_) {} })();";
|
||||
|
||||
static GtkApplication *app;
|
||||
static GtkWindow *window;
|
||||
static WebKitWebView *view;
|
||||
static WebKitNetworkSession *session;
|
||||
static int listener = -1;
|
||||
static int client = -1;
|
||||
static guint client_read_source;
|
||||
static guint client_write_source;
|
||||
static GByteArray *rx;
|
||||
static GQueue *tx;
|
||||
static size_t tx_offset;
|
||||
static gboolean domain_title;
|
||||
static gboolean login_page;
|
||||
static gboolean back_disabled;
|
||||
static gboolean shutting_down;
|
||||
static char *last_cookie;
|
||||
|
||||
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data);
|
||||
static gboolean on_client(gint fd, GIOCondition cond, gpointer data);
|
||||
static void apply(uint8_t op, const char *payload, size_t size);
|
||||
static void create_surface(void);
|
||||
static void destroy_surface(void);
|
||||
|
||||
static void drop_client(const char *why) {
|
||||
if (client < 0) return;
|
||||
fprintf(stderr, "client dropped: %s\n", why);
|
||||
if (client_read_source) {
|
||||
g_source_remove(client_read_source);
|
||||
client_read_source = 0;
|
||||
}
|
||||
if (client_write_source) {
|
||||
g_source_remove(client_write_source);
|
||||
client_write_source = 0;
|
||||
}
|
||||
close(client);
|
||||
client = -1;
|
||||
g_byte_array_set_size(rx, 0);
|
||||
while (!g_queue_is_empty(tx)) g_bytes_unref(g_queue_pop_head(tx));
|
||||
tx_offset = 0;
|
||||
g_free(last_cookie);
|
||||
last_cookie = NULL;
|
||||
destroy_surface();
|
||||
}
|
||||
|
||||
static void quit_app(void) {
|
||||
if (shutting_down) return;
|
||||
shutting_down = TRUE;
|
||||
drop_client("shutting down");
|
||||
if (app) g_application_quit(G_APPLICATION(app));
|
||||
}
|
||||
|
||||
static void send_event(uint8_t type, const char *payload, size_t size) {
|
||||
if (client < 0) return;
|
||||
size_t body = 12 + size;
|
||||
uint8_t *buffer = g_malloc0(4 + body);
|
||||
buffer[0] = body >> 24; buffer[1] = body >> 16; buffer[2] = body >> 8; buffer[3] = body;
|
||||
memcpy(buffer + 4, "MWVE", 4);
|
||||
buffer[8] = 1;
|
||||
buffer[9] = type;
|
||||
buffer[12] = size >> 24; buffer[13] = size >> 16; buffer[14] = size >> 8; buffer[15] = size;
|
||||
if (size) memcpy(buffer + 16, payload, size);
|
||||
g_queue_push_tail(tx, g_bytes_new_take(buffer, 4 + body));
|
||||
if (!client_write_source)
|
||||
client_write_source = g_unix_fd_add(client, G_IO_OUT | G_IO_HUP | G_IO_ERR, flush_tx, NULL);
|
||||
}
|
||||
|
||||
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data) {
|
||||
(void)data;
|
||||
if (cond & (G_IO_HUP | G_IO_ERR)) {
|
||||
client_write_source = 0;
|
||||
drop_client("write side closed");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
while (!g_queue_is_empty(tx)) {
|
||||
GBytes *head = g_queue_peek_head(tx);
|
||||
gsize length;
|
||||
const uint8_t *bytes = g_bytes_get_data(head, &length);
|
||||
ssize_t count = send(fd, bytes + tx_offset, length - tx_offset, MSG_DONTWAIT | MSG_NOSIGNAL);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return G_SOURCE_CONTINUE;
|
||||
if (count <= 0) {
|
||||
client_write_source = 0;
|
||||
drop_client("write failed");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
tx_offset += (size_t)count;
|
||||
if (tx_offset == length) {
|
||||
tx_offset = 0;
|
||||
g_bytes_unref(g_queue_pop_head(tx));
|
||||
}
|
||||
}
|
||||
client_write_source = 0;
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static char *user_agent(void) {
|
||||
const char *v = getenv("MOCKTAIL_ROBLOX_VERSION");
|
||||
if (!v || !*v) v = VERSION;
|
||||
return g_strdup_printf(
|
||||
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) ROBLOX Android App "
|
||||
"%s Tablet Hybrid() GooglePlayStore RobloxApp/%s (GlobalDist; GooglePlayStore)", v, v);
|
||||
}
|
||||
|
||||
static void on_cookies(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
GList *cookies = webkit_cookie_manager_get_cookies_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
|
||||
for (GList *i = cookies; i; i = i->next) {
|
||||
SoupCookie *c = i->data;
|
||||
const char *name = soup_cookie_get_name(c);
|
||||
const char *value = soup_cookie_get_value(c);
|
||||
if (name && value && strcmp(name, ".ROBLOSECURITY") == 0 && *value &&
|
||||
(!last_cookie || strcmp(last_cookie, value) != 0)) {
|
||||
g_free(last_cookie);
|
||||
last_cookie = g_strdup(value);
|
||||
send_event(EV_COOKIE, value, strlen(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_list_free_full(cookies, (GDestroyNotify)soup_cookie_free);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void request_cookie(void) {
|
||||
if (!login_page || !session || client < 0) return;
|
||||
webkit_cookie_manager_get_cookies(webkit_network_session_get_cookie_manager(session),
|
||||
"https://www.roblox.com/", NULL, on_cookies, NULL);
|
||||
}
|
||||
|
||||
static void on_cookie_changed(WebKitCookieManager *manager, gpointer data) {
|
||||
(void)manager; (void)data;
|
||||
request_cookie();
|
||||
}
|
||||
|
||||
static void configure_agent(const char *url) {
|
||||
WebKitSettings *settings = webkit_web_view_get_settings(view);
|
||||
GUri *uri = g_uri_parse(url, G_URI_FLAGS_NONE, NULL);
|
||||
const char *path = uri ? g_uri_get_path(uri) : NULL;
|
||||
login_page = path && (strcmp(path, "/login") == 0 || strcmp(path, "/login/") == 0);
|
||||
if (uri) g_uri_unref(uri);
|
||||
if (login_page) {
|
||||
webkit_settings_set_user_agent(settings, NULL);
|
||||
request_cookie();
|
||||
} else {
|
||||
char *agent = user_agent();
|
||||
webkit_settings_set_user_agent(settings, agent);
|
||||
g_free(agent);
|
||||
}
|
||||
}
|
||||
|
||||
static void update_title(void) {
|
||||
if (!domain_title || !window || !view) return;
|
||||
const char *uri = webkit_web_view_get_uri(view);
|
||||
if (!uri) return;
|
||||
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_NONE, NULL);
|
||||
if (!parsed) return;
|
||||
const char *host = g_uri_get_host(parsed);
|
||||
if (host) gtk_window_set_title(window, host);
|
||||
g_uri_unref(parsed);
|
||||
}
|
||||
|
||||
static void on_load_changed(WebKitWebView *v, WebKitLoadEvent event, gpointer data) {
|
||||
(void)v; (void)data;
|
||||
if (event == WEBKIT_LOAD_COMMITTED || event == WEBKIT_LOAD_FINISHED) {
|
||||
update_title();
|
||||
request_cookie();
|
||||
}
|
||||
}
|
||||
|
||||
static GtkWidget *on_create(WebKitWebView *v, WebKitNavigationAction *action, gpointer data) {
|
||||
(void)v; (void)data;
|
||||
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
|
||||
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
|
||||
if (uri && view) webkit_web_view_load_uri(view, uri);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char *json_string_after(const char *json, const char *key) {
|
||||
char *needle = g_strdup_printf("\"%s\"", key);
|
||||
const char *at = strstr(json, needle);
|
||||
g_free(needle);
|
||||
if (!at) return NULL;
|
||||
at = strchr(at + strlen(key) + 2, ':');
|
||||
if (!at) return NULL;
|
||||
at++;
|
||||
while (*at == ' ') at++;
|
||||
if (*at != '"') return NULL;
|
||||
at++;
|
||||
GString *out = g_string_new(NULL);
|
||||
while (*at && *at != '"') {
|
||||
if (*at == '\\' && at[1]) at++;
|
||||
g_string_append_c(out, *at);
|
||||
at++;
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
static void on_callback_done(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
gboolean close_after = GPOINTER_TO_INT(data);
|
||||
GError *error = NULL;
|
||||
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
|
||||
gboolean confirmed = !error && value && jsc_value_is_boolean(value) && jsc_value_to_boolean(value);
|
||||
if (value) g_object_unref(value);
|
||||
g_clear_error(&error);
|
||||
if (confirmed && close_after) destroy_surface();
|
||||
}
|
||||
|
||||
static void acknowledge_page_command(const char *json) {
|
||||
if (!view || !json) return;
|
||||
char *feature = json_string_after(json, "feature");
|
||||
char *callback = json_string_after(json, "callbackID");
|
||||
if (feature && callback && *callback) {
|
||||
gboolean success = strcmp(feature, "CaptchaSuccess") == 0;
|
||||
if (success || strcmp(feature, "CaptchaShown") == 0) {
|
||||
GString *quoted = g_string_new("\"");
|
||||
for (const char *p = callback; *p; p++) {
|
||||
if (*p == '"' || *p == '\\') g_string_append_c(quoted, '\\');
|
||||
g_string_append_c(quoted, *p);
|
||||
}
|
||||
g_string_append_c(quoted, '"');
|
||||
char *script = g_strdup_printf(
|
||||
"(() => { try { if (window.Roblox && window.Roblox.Hybrid && "
|
||||
"window.Roblox.Hybrid.Bridge && typeof window.Roblox.Hybrid.Bridge.nativeCallback === 'function') { "
|
||||
"window.Roblox.Hybrid.Bridge.nativeCallback(%s, true, {}); return true; } } catch (_) {} return false; })();",
|
||||
quoted->str);
|
||||
webkit_web_view_evaluate_javascript(view, script, -1, NULL, NULL, NULL,
|
||||
on_callback_done, GINT_TO_POINTER(success));
|
||||
g_free(script);
|
||||
g_string_free(quoted, TRUE);
|
||||
}
|
||||
}
|
||||
g_free(feature);
|
||||
g_free(callback);
|
||||
}
|
||||
|
||||
static void forward(uint8_t type, JSCValue *value) {
|
||||
char *text = NULL;
|
||||
if (jsc_value_is_string(value)) text = jsc_value_to_string(value);
|
||||
else if (jsc_value_is_object(value)) text = jsc_value_to_json(value, 0);
|
||||
if (!text) return;
|
||||
size_t size = strlen(text);
|
||||
if (size && size <= 64u * 1024u) {
|
||||
send_event(type, text, size);
|
||||
acknowledge_page_command(text);
|
||||
}
|
||||
g_free(text);
|
||||
}
|
||||
|
||||
static void on_execute_roblox(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
|
||||
(void)m; (void)data;
|
||||
forward(EV_EXECUTE_ROBLOX, value);
|
||||
}
|
||||
|
||||
static void on_hybrid(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
|
||||
(void)m; (void)data;
|
||||
if (!jsc_value_is_object(value)) return;
|
||||
JSCValue *command = jsc_value_object_get_property(value, "command");
|
||||
if (command) {
|
||||
forward(EV_ROBLOX_WK_HYBRID, command);
|
||||
g_object_unref(command);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_view_close(WebKitWebView *v, gpointer data) {
|
||||
(void)v; (void)data;
|
||||
destroy_surface();
|
||||
}
|
||||
|
||||
static gboolean on_close_request(GtkWindow *w, gpointer data) {
|
||||
(void)w; (void)data;
|
||||
window = NULL;
|
||||
view = NULL;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void on_reload(GtkButton *button, gpointer data) {
|
||||
(void)button; (void)data;
|
||||
if (view) webkit_web_view_reload_bypass_cache(view);
|
||||
}
|
||||
|
||||
static void on_eval(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
|
||||
if (value) g_object_unref(value);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void on_cookie_done(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
webkit_cookie_manager_add_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void on_cookie_deleted(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
webkit_cookie_manager_delete_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void ensure_session(void) {
|
||||
if (session) return;
|
||||
gchar *data_dir = g_build_filename(g_get_user_data_dir(), "mocktail", "webview", NULL);
|
||||
gchar *cache_dir = g_build_filename(g_get_user_cache_dir(), "mocktail", "webview", NULL);
|
||||
g_mkdir_with_parents(data_dir, 0700);
|
||||
g_mkdir_with_parents(cache_dir, 0700);
|
||||
gchar *cookie_db = g_build_filename(data_dir, "cookies.sqlite", NULL);
|
||||
session = webkit_network_session_new(data_dir, cache_dir);
|
||||
g_free(data_dir);
|
||||
g_free(cache_dir);
|
||||
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
|
||||
webkit_cookie_manager_set_persistent_storage(cookies, cookie_db, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
|
||||
webkit_cookie_manager_set_accept_policy(cookies, WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS);
|
||||
g_signal_connect(cookies, "changed", G_CALLBACK(on_cookie_changed), NULL);
|
||||
g_free(cookie_db);
|
||||
webkit_network_session_set_itp_enabled(session, FALSE);
|
||||
webkit_network_session_set_persistent_credential_storage_enabled(session, TRUE);
|
||||
}
|
||||
|
||||
static void create_surface(void) {
|
||||
if (window || view) return;
|
||||
ensure_session();
|
||||
|
||||
WebKitUserContentManager *manager = webkit_user_content_manager_new();
|
||||
WebKitWebView *created = WEBKIT_WEB_VIEW(g_object_new(WEBKIT_TYPE_WEB_VIEW, "network-session", session,
|
||||
"user-content-manager", manager, NULL));
|
||||
g_object_unref(manager);
|
||||
manager = webkit_web_view_get_user_content_manager(created);
|
||||
g_signal_connect(manager, "script-message-received::executeRoblox", G_CALLBACK(on_execute_roblox), NULL);
|
||||
g_signal_connect(manager, "script-message-received::RobloxWKHybrid", G_CALLBACK(on_hybrid), NULL);
|
||||
g_signal_connect(manager, "script-message-received::mocktailRobloxBridge", G_CALLBACK(on_execute_roblox), NULL);
|
||||
webkit_user_content_manager_register_script_message_handler(manager, "executeRoblox", NULL);
|
||||
webkit_user_content_manager_register_script_message_handler(manager, "RobloxWKHybrid", NULL);
|
||||
webkit_user_content_manager_register_script_message_handler(manager, "mocktailRobloxBridge", NULL);
|
||||
WebKitUserScript *script = webkit_user_script_new(kBridge, WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, NULL, NULL);
|
||||
webkit_user_content_manager_add_script(manager, script);
|
||||
webkit_user_script_unref(script);
|
||||
|
||||
WebKitSettings *settings = webkit_web_view_get_settings(created);
|
||||
webkit_settings_set_enable_javascript(settings, TRUE);
|
||||
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
|
||||
webkit_settings_set_enable_back_forward_navigation_gestures(settings, !back_disabled);
|
||||
char *agent = user_agent();
|
||||
webkit_settings_set_user_agent(settings, agent);
|
||||
g_free(agent);
|
||||
|
||||
g_signal_connect(created, "load-changed", G_CALLBACK(on_load_changed), NULL);
|
||||
g_signal_connect(created, "create", G_CALLBACK(on_create), NULL);
|
||||
g_signal_connect(created, "close", G_CALLBACK(on_view_close), NULL);
|
||||
|
||||
GtkWindow *created_window = GTK_WINDOW(gtk_application_window_new(app));
|
||||
gtk_window_set_title(created_window, "Roblox");
|
||||
gtk_window_set_default_size(created_window, 1100, 760);
|
||||
GtkWidget *header = gtk_header_bar_new();
|
||||
GtkWidget *reload = gtk_button_new_from_icon_name("view-refresh-symbolic");
|
||||
g_signal_connect(reload, "clicked", G_CALLBACK(on_reload), NULL);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), reload);
|
||||
gtk_window_set_titlebar(created_window, header);
|
||||
gtk_widget_set_vexpand(GTK_WIDGET(created), TRUE);
|
||||
gtk_window_set_child(created_window, GTK_WIDGET(created));
|
||||
g_signal_connect(created_window, "close-request", G_CALLBACK(on_close_request), NULL);
|
||||
|
||||
view = created;
|
||||
window = created_window;
|
||||
}
|
||||
|
||||
static void destroy_surface(void) {
|
||||
GtkWindow *w = window;
|
||||
window = NULL;
|
||||
view = NULL;
|
||||
login_page = FALSE;
|
||||
domain_title = FALSE;
|
||||
if (w) gtk_window_destroy(w);
|
||||
}
|
||||
|
||||
static void apply(uint8_t op, const char *payload, size_t size) {
|
||||
if (op == OP_CLOSE) {
|
||||
destroy_surface();
|
||||
return;
|
||||
}
|
||||
if (op == OP_SET_BACK_DISABLED) {
|
||||
back_disabled = size == 1 && payload[0];
|
||||
if (view) webkit_settings_set_enable_back_forward_navigation_gestures(
|
||||
webkit_web_view_get_settings(view), !back_disabled);
|
||||
return;
|
||||
}
|
||||
if (op == OP_SHOW_DOMAIN_TITLE) {
|
||||
domain_title = size == 1 && payload[0];
|
||||
update_title();
|
||||
return;
|
||||
}
|
||||
if (op == OP_SET_COOKIE || op == OP_CLEAR_COOKIE) {
|
||||
ensure_session();
|
||||
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
|
||||
if (op == OP_SET_COOKIE) {
|
||||
char *value = g_strndup(payload, size);
|
||||
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", value, ".roblox.com", "/", -1);
|
||||
g_free(value);
|
||||
if (cookie) {
|
||||
soup_cookie_set_secure(cookie, TRUE);
|
||||
soup_cookie_set_http_only(cookie, TRUE);
|
||||
soup_cookie_set_same_site_policy(cookie, SOUP_SAME_SITE_POLICY_LAX);
|
||||
webkit_cookie_manager_add_cookie(cookies, cookie, NULL, on_cookie_done, NULL);
|
||||
soup_cookie_free(cookie);
|
||||
}
|
||||
} else {
|
||||
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", "", ".roblox.com", "/", -1);
|
||||
if (cookie) {
|
||||
webkit_cookie_manager_delete_cookie(cookies, cookie, NULL, on_cookie_deleted, NULL);
|
||||
soup_cookie_free(cookie);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (op == OP_RETAIN_COOKIE) return;
|
||||
if (op == OP_LOAD_URL) create_surface();
|
||||
if (!window || !view) return;
|
||||
switch (op) {
|
||||
case OP_LOAD_URL: {
|
||||
char *url = g_strndup(payload, size);
|
||||
configure_agent(url);
|
||||
webkit_web_view_load_uri(view, url);
|
||||
gtk_window_present(window);
|
||||
g_free(url);
|
||||
break;
|
||||
}
|
||||
case OP_SET_TITLE: {
|
||||
char *title = g_strndup(payload, size);
|
||||
gtk_window_set_title(window, title);
|
||||
g_free(title);
|
||||
break;
|
||||
}
|
||||
case OP_SET_VISIBLE:
|
||||
if (size == 1 && payload[0]) gtk_window_present(window);
|
||||
else gtk_widget_set_visible(GTK_WIDGET(window), FALSE);
|
||||
break;
|
||||
case OP_EVAL_JS:
|
||||
webkit_web_view_evaluate_javascript(view, payload, (gssize)size, NULL, NULL, NULL, on_eval, NULL);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static gboolean process_frames(void) {
|
||||
while (rx->len >= 4) {
|
||||
uint32_t length = ((uint32_t)rx->data[0] << 24) | ((uint32_t)rx->data[1] << 16) |
|
||||
((uint32_t)rx->data[2] << 8) | rx->data[3];
|
||||
if (length > MAX_PACKET) return FALSE;
|
||||
if (rx->len < 4u + length) break;
|
||||
uint8_t *copy = g_memdup2(rx->data + 4, length);
|
||||
g_byte_array_remove_range(rx, 0, 4 + length);
|
||||
if (length >= 12 && memcmp(copy, "MWVC", 4) == 0) {
|
||||
size_t size = ((size_t)copy[8] << 24) | ((size_t)copy[9] << 16) |
|
||||
((size_t)copy[10] << 8) | copy[11];
|
||||
if (size == length - 12) apply(copy[5], (const char *)copy + 12, size);
|
||||
}
|
||||
g_free(copy);
|
||||
if (client < 0) return TRUE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean on_client(gint fd, GIOCondition cond, gpointer data) {
|
||||
(void)data;
|
||||
uint8_t buffer[8192];
|
||||
while (TRUE) {
|
||||
ssize_t count = read(fd, buffer, sizeof buffer);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
|
||||
if (count <= 0) {
|
||||
client_read_source = 0;
|
||||
drop_client("client closed");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
g_byte_array_append(rx, buffer, (guint)count);
|
||||
if (!process_frames()) {
|
||||
client_read_source = 0;
|
||||
drop_client("bad frame");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
if (client < 0) return G_SOURCE_REMOVE;
|
||||
}
|
||||
if (cond & (G_IO_HUP | G_IO_ERR)) {
|
||||
client_read_source = 0;
|
||||
drop_client("client hung up");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static gboolean on_listener(gint fd, GIOCondition cond, gpointer data) {
|
||||
(void)cond; (void)data;
|
||||
int accepted = accept(fd, NULL, NULL);
|
||||
if (accepted < 0) return G_SOURCE_CONTINUE;
|
||||
if (client >= 0) drop_client("replaced by a new connection");
|
||||
fcntl(accepted, F_SETFD, FD_CLOEXEC);
|
||||
fcntl(accepted, F_SETFL, fcntl(accepted, F_GETFL) | O_NONBLOCK);
|
||||
int one = 1;
|
||||
setsockopt(accepted, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||
client = accepted;
|
||||
fprintf(stderr, "client connected\n");
|
||||
client_read_source = g_unix_fd_add(client, G_IO_IN | G_IO_HUP | G_IO_ERR, on_client, NULL);
|
||||
send_event(EV_READY, "", 0);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static int listen_socket(void) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
fcntl(fd, F_SETFD, FD_CLOEXEC);
|
||||
int one = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
||||
struct sockaddr_in address;
|
||||
memset(&address, 0, sizeof address);
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons(PORT);
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(fd, (struct sockaddr *)&address, sizeof address) != 0 || listen(fd, 4) != 0) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static void activate(GtkApplication *application, gpointer data) {
|
||||
(void)application; (void)data;
|
||||
}
|
||||
|
||||
static gboolean on_sigterm(gpointer data) {
|
||||
(void)data;
|
||||
quit_app();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 0);
|
||||
setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 0);
|
||||
rx = g_byte_array_new();
|
||||
tx = g_queue_new();
|
||||
listener = listen_socket();
|
||||
if (listener < 0) {
|
||||
fprintf(stderr, "cannot listen on 127.0.0.1:%d: %s\n", PORT, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
g_unix_fd_add(listener, G_IO_IN, on_listener, NULL);
|
||||
g_unix_signal_add(SIGTERM, on_sigterm, NULL);
|
||||
g_unix_signal_add(SIGINT, on_sigterm, NULL);
|
||||
app = gtk_application_new("org.mocktail.WebViewHost", G_APPLICATION_NON_UNIQUE);
|
||||
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
|
||||
g_application_hold(G_APPLICATION(app));
|
||||
int status = g_application_run(G_APPLICATION(app), 1, argv);
|
||||
g_object_unref(app);
|
||||
return status;
|
||||
}
|
||||
@@ -28,16 +28,12 @@
|
||||
</description>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-home.png</image>
|
||||
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-home.png</image>
|
||||
<caption>Browse Roblox experiences</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-tower.png</image>
|
||||
<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>
|
||||
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-gameplay-tower.png</image>
|
||||
<caption>Play Murder Mystery 2 on Linux</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<launchable type="desktop-id">io.github.CoderDayton.nightcap.desktop</launchable>
|
||||
@@ -57,6 +53,44 @@
|
||||
<binary>mocktail</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="0.3.4" date="2026-10-11">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed Roblox 2.742 being rejected as an update.</li>
|
||||
<li>The new Roblox home screen and its side menu now show.</li>
|
||||
<li>Fixed the voice chat microphone after Roblox updates.</li>
|
||||
<li>Anti-aliasing is no longer forced off.</li>
|
||||
<li>Nightcap now uses Wayland on NVIDIA Wayland sessions.</li>
|
||||
<li>Fixed a missing picture on machines whose graphics card runs a different driver.</li>
|
||||
<li>Fewer frame hitches while textures load.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.3.3" date="2026-10-04">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Joining a friend from a follow user link now works.</li>
|
||||
<li>Roblox textures decode about 3 times faster, so loading stalls are shorter.</li>
|
||||
<li>A failed Roblox update now restores your previous install instead of leaving it half changed.</li>
|
||||
<li>Fixed crashes and freezes with audio, the text overlay, the mouse lock and saved themes.</li>
|
||||
<li>Going to another page in the web view no longer closes it.</li>
|
||||
<li>Clicking while typing no longer closes the text box.</li>
|
||||
<li>Fixed the sky showing the wrong textures.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.3.2" date="2026-09-29">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Added experimental support for ARM64 Linux.</li>
|
||||
<li>Fixed login stalling after passing a verification check.</li>
|
||||
<li>Fixed login check windows staying open after they were solved.</li>
|
||||
<li>Fixed Play on a private server doing nothing until a sidebar tab was clicked.</li>
|
||||
<li>Less stutter while joining a game and while textures load in.</li>
|
||||
<li>Middle click and natural scrolling now work as expected.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.3.1" date="2026-09-20">
|
||||
<description>
|
||||
<ul>
|
||||
|
||||
@@ -62,7 +62,7 @@ Branch=stable
|
||||
Url=${BASE_URL}/repo/
|
||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
Homepage=https://github.com/CoderDayton/nightcap
|
||||
Comment=Roblox on Linux, tuned for a real PC
|
||||
Comment=Roblox on Linux, tuned for desktop play
|
||||
Icon=${BASE_URL}/nightcap.svg
|
||||
GPGKey=${GPG_KEY}
|
||||
IsRuntime=false
|
||||
@@ -72,3 +72,20 @@ 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/terms.html" "${OUTPUT}/terms.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
|
||||
@@ -461,6 +461,7 @@ LatestSupportedBootstrapProfile() {
|
||||
.version_code <= ($remote_version_code | tonumber)) and
|
||||
.status == "supported" and .default_allowed == true and
|
||||
.allow_legacy_binary_patches == false and
|
||||
(.abi // "x86_64") == "x86_64" and
|
||||
(.version_name | type) == "string" and
|
||||
(.version_name | length) > 0)] |
|
||||
group_by(.version_code) |
|
||||
@@ -495,6 +496,7 @@ SelectSupportedBootstrapFallback() {
|
||||
bootstrap_source="$(jq -er --argjson version_code "${fallback_version_code}" '
|
||||
[.sources[]? |
|
||||
select(.version_code == $version_code and
|
||||
(.abi // "x86_64") == "x86_64" and
|
||||
.provider == "uptodown")] |
|
||||
if length == 1 then .[0].provider else empty end
|
||||
' "${BOOTSTRAP_SOURCES_PATH}" 2>/dev/null || true)"
|
||||
|
||||
@@ -329,6 +329,7 @@ def run_trial(args, max_ctors, skipped_offsets, trial_id):
|
||||
log_path = args.log_dir / f"trial_{trial_id:03d}_max_{max_ctors}.log"
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"ROBLOX_LIB_PATH": str(args.libroblox),
|
||||
"MOCKTAIL_RUN_LIBROBLOX_CTORS": "1",
|
||||
"MOCKTAIL_LIBROBLOX_CTOR_POLICY": args.ctor_policy,
|
||||
"MOCKTAIL_GRAPHICS_BACKEND": args.graphics_backend,
|
||||
@@ -500,7 +501,7 @@ def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project-root", type=Path, default=project_root)
|
||||
parser.add_argument("--run-script", type=Path,
|
||||
default=project_root / "scripts" / "run_sober.sh")
|
||||
default=project_root / "build" / "mocktail")
|
||||
parser.add_argument("--libroblox", type=Path,
|
||||
default=project_root / "rbx_bin" / "libroblox.so")
|
||||
parser.add_argument("--log-dir", type=Path,
|
||||
|
||||
@@ -10,18 +10,18 @@ readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
|
||||
|
||||
BUILD_DIR="${PROJECT_ROOT}/build-flatpak"
|
||||
MANIFEST="${PROJECT_ROOT}/packaging/flatpak/io.github.CoderDayton.nightcap.json"
|
||||
JOBS="${MOCKTAIL_FLATPAK_JOBS:-4}"
|
||||
JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"
|
||||
|
||||
Usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/build_flatpak.sh [OPTIONS]
|
||||
|
||||
Build and install Mocktail as a per-user x86-64 Flatpak.
|
||||
Build and install Mocktail as a per-user Flatpak for the host architecture.
|
||||
|
||||
Options:
|
||||
--build-dir DIR Builder output directory (default: build-flatpak).
|
||||
--manifest FILE Flatpak manifest path.
|
||||
--jobs N Maximum parallel build jobs (default: 4).
|
||||
--jobs N Maximum parallel build jobs (default: 2).
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
@@ -93,8 +93,11 @@ done
|
||||
[[ "${JOBS}" =~ ^[1-9][0-9]*$ && "${JOBS}" -le 256 ]] ||
|
||||
Die "--jobs must be an integer between 1 and 256"
|
||||
|
||||
[[ "$(uname -m)" == x86_64 ]] ||
|
||||
Die "the Roblox payload and Flatpak manifest currently require x86-64"
|
||||
HOST_ARCH="$(uname -m)"
|
||||
case "${HOST_ARCH}" in
|
||||
x86_64|aarch64) ;;
|
||||
*) Die "Flatpak builds require an x86_64 or aarch64 Linux host" ;;
|
||||
esac
|
||||
command -v flatpak >/dev/null 2>&1 || Die "flatpak is required"
|
||||
|
||||
MANIFEST="$(realpath -e -- "${MANIFEST}")" ||
|
||||
@@ -123,7 +126,7 @@ CleanupStaleBuilderMounts
|
||||
|
||||
cd -- "${PROJECT_ROOT}"
|
||||
exec "${builder[@]}" \
|
||||
--arch=x86_64 \
|
||||
--arch="${HOST_ARCH}" \
|
||||
--jobs="${JOBS}" \
|
||||
--force-clean \
|
||||
--install-deps-from=flathub \
|
||||
|
||||
@@ -61,6 +61,7 @@ def supported_profiles(catalog: dict) -> list[dict]:
|
||||
if isinstance(profile, dict)
|
||||
and profile.get("status") == "supported"
|
||||
and profile.get("default_allowed") is True
|
||||
and profile.get("abi", ARCHITECTURE) == ARCHITECTURE
|
||||
and isinstance(profile.get("version_name"), str)
|
||||
and isinstance(profile.get("version_code"), int)
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@ supported.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
@@ -161,6 +162,10 @@ class SignatureSpec:
|
||||
allow_registry_object_size_change: bool = False
|
||||
minimum_anchor_bytes: int = 6
|
||||
registry_size_indices: tuple[int, int] = (8, 12)
|
||||
# Instructions whose register operands the compiler may have chosen
|
||||
# differently. Each reference register must stand for one candidate
|
||||
# register across all of them, and the reverse.
|
||||
renamed_register_indices: tuple[int, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1075,6 +1080,51 @@ def semantic_shape(
|
||||
)
|
||||
|
||||
|
||||
def register_family(name: str) -> str:
|
||||
"""Return the 64-bit register that name is all or part of."""
|
||||
if len(name) >= 2 and name[0] == "r" and name[1].isdigit():
|
||||
return name.rstrip("bwd")
|
||||
for family in ("ax", "bx", "cx", "dx", "si", "di", "bp", "sp"):
|
||||
if name in (family, "e" + family, "r" + family):
|
||||
return "r" + family
|
||||
return name
|
||||
|
||||
|
||||
def renamed_shapes_match(
|
||||
reference_shape: tuple[Any, ...],
|
||||
candidate_shape: tuple[Any, ...],
|
||||
forward: dict[str, str],
|
||||
backward: dict[str, str],
|
||||
) -> bool:
|
||||
"""Compare two semantic shapes, letting register operands be renamed.
|
||||
|
||||
forward and backward hold the renames accepted so far and gain new ones.
|
||||
"""
|
||||
reference_mnemonic, reference_operands = reference_shape
|
||||
candidate_mnemonic, candidate_operands = candidate_shape
|
||||
if reference_mnemonic != candidate_mnemonic or len(reference_operands) != len(
|
||||
candidate_operands
|
||||
):
|
||||
return False
|
||||
for left, right in zip(reference_operands, candidate_operands):
|
||||
if left[0] != "reg" or right[0] != "reg":
|
||||
if left != right:
|
||||
return False
|
||||
continue
|
||||
if left[2] != right[2]:
|
||||
return False
|
||||
reference_family = register_family(left[1])
|
||||
candidate_family = register_family(right[1])
|
||||
if (
|
||||
forward.setdefault(reference_family, candidate_family)
|
||||
!= candidate_family
|
||||
or backward.setdefault(candidate_family, reference_family)
|
||||
!= reference_family
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_semantic_match(
|
||||
reference: Sequence[Any],
|
||||
candidate: Sequence[Any],
|
||||
@@ -1082,6 +1132,8 @@ def validate_semantic_match(
|
||||
) -> None:
|
||||
if len(reference) != len(candidate):
|
||||
raise AnalyzerError("normalized signature instruction count changed")
|
||||
forward: dict[str, str] = {}
|
||||
backward: dict[str, str] = {}
|
||||
for index, (reference_instruction, candidate_instruction) in enumerate(
|
||||
zip(reference, candidate)
|
||||
):
|
||||
@@ -1089,8 +1141,14 @@ def validate_semantic_match(
|
||||
spec.allow_registry_object_size_change
|
||||
and index in spec.registry_size_indices
|
||||
)
|
||||
if semantic_shape(reference_instruction, wildcard_size) != semantic_shape(
|
||||
candidate_instruction, wildcard_size
|
||||
reference_shape = semantic_shape(reference_instruction, wildcard_size)
|
||||
candidate_shape = semantic_shape(candidate_instruction, wildcard_size)
|
||||
if (
|
||||
not renamed_shapes_match(
|
||||
reference_shape, candidate_shape, forward, backward
|
||||
)
|
||||
if index in spec.renamed_register_indices
|
||||
else reference_shape != candidate_shape
|
||||
):
|
||||
raise AnalyzerError(
|
||||
f"normalized instruction semantics changed at signature index {index}"
|
||||
@@ -1298,6 +1356,35 @@ def has_fmod_device_lists_select_contract(image: ElfImage, rva: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
FMOD_OUTPUT_FIELDS = frozenset({
|
||||
"vtable_rva",
|
||||
"string_constructor_rva",
|
||||
"count_method_rva",
|
||||
"info_method_rva",
|
||||
"current_method_rva",
|
||||
"select_method_rva",
|
||||
})
|
||||
FMOD_INPUT_FIELDS = (
|
||||
"input_count_method_rva",
|
||||
"input_info_method_rva",
|
||||
"input_current_method_rva",
|
||||
"input_select_method_rva",
|
||||
)
|
||||
|
||||
|
||||
def fmod_vtable_slots(layout: int, with_input: bool) -> dict[str, int]:
|
||||
"""Vtable slot of each FMOD method field in vtable layout 1 or 2."""
|
||||
slots = {
|
||||
"count_method_rva": 5,
|
||||
"info_method_rva": 6,
|
||||
"current_method_rva": 8 if layout == 2 else 7,
|
||||
"select_method_rva": 19 if layout == 2 else 17,
|
||||
}
|
||||
if with_input:
|
||||
slots.update(zip(FMOD_INPUT_FIELDS, (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)))
|
||||
return slots
|
||||
|
||||
|
||||
def load_reference_runtime_profile(
|
||||
paths: Sequence[Path], reference_build_id: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -1323,21 +1410,11 @@ def load_reference_runtime_profile(
|
||||
)
|
||||
if "fmod_output_device_bridge" in profile:
|
||||
bridge = profile["fmod_output_device_bridge"]
|
||||
fields = {
|
||||
"vtable_rva",
|
||||
"string_constructor_rva",
|
||||
"count_method_rva",
|
||||
"info_method_rva",
|
||||
"current_method_rva",
|
||||
"select_method_rva",
|
||||
}
|
||||
if (
|
||||
not isinstance(bridge, dict)
|
||||
or not fields <= set(bridge)
|
||||
or set(bridge) - fields - {"vtable_layout_version"} - {
|
||||
"input_count_method_rva", "input_info_method_rva",
|
||||
"input_current_method_rva", "input_select_method_rva",
|
||||
}
|
||||
or not FMOD_OUTPUT_FIELDS <= set(bridge)
|
||||
or set(bridge) - FMOD_OUTPUT_FIELDS - {"vtable_layout_version"}
|
||||
- set(FMOD_INPUT_FIELDS)
|
||||
or profile.get("allow_host_abi_bridges") is not True
|
||||
):
|
||||
raise AnalyzerError(
|
||||
@@ -1357,10 +1434,7 @@ def load_reference_runtime_profile(
|
||||
if field in result:
|
||||
previous = result[field]
|
||||
if field == "fmod_output_device_bridge":
|
||||
input_fields = {
|
||||
"input_count_method_rva", "input_info_method_rva",
|
||||
"input_current_method_rva", "input_select_method_rva",
|
||||
}
|
||||
input_fields = set(FMOD_INPUT_FIELDS)
|
||||
compatible = ((set(previous) ^ set(value)) <= input_fields and
|
||||
all(previous[key] == value[key]
|
||||
for key in previous.keys() & value.keys()))
|
||||
@@ -1382,12 +1456,7 @@ def derive_fmod_output_device_bridge(
|
||||
source: dict[str, int],
|
||||
) -> dict[str, Any]:
|
||||
source_layout = source.get("vtable_layout_version", 1)
|
||||
indexes = {
|
||||
"count_method_rva": 5,
|
||||
"info_method_rva": 6,
|
||||
"current_method_rva": 8 if source_layout == 2 else 7,
|
||||
"select_method_rva": 19 if source_layout == 2 else 17,
|
||||
}
|
||||
indexes = fmod_vtable_slots(source_layout, False)
|
||||
source_vtable = source["vtable_rva"]
|
||||
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
|
||||
reference_relocations = relative_relocation_map(reference)
|
||||
@@ -1472,7 +1541,10 @@ def derive_fmod_output_device_bridge(
|
||||
continue
|
||||
vtable = offset - indexes["info_method_rva"] * 8
|
||||
count_method = relocations.get(vtable + indexes["count_method_rva"] * 8)
|
||||
for layout, current_index, select_index in ((1, 7, 17), (2, 8, 19)):
|
||||
for layout in (1, 2):
|
||||
layout_slots = fmod_vtable_slots(layout, False)
|
||||
current_index = layout_slots["current_method_rva"]
|
||||
select_index = layout_slots["select_method_rva"]
|
||||
current_method = relocations.get(vtable + current_index * 8)
|
||||
selected = relocations.get(vtable + select_index * 8)
|
||||
if (
|
||||
@@ -1510,18 +1582,13 @@ def derive_fmod_output_device_bridge(
|
||||
}
|
||||
if layout != 1:
|
||||
result["vtable_layout_version"] = layout
|
||||
input_fields = (
|
||||
"input_count_method_rva", "input_info_method_rva",
|
||||
"input_current_method_rva", "input_select_method_rva",
|
||||
)
|
||||
if any(field in source for field in input_fields):
|
||||
if not all(field in source for field in input_fields):
|
||||
if any(field in source for field in FMOD_INPUT_FIELDS):
|
||||
if not all(field in source for field in FMOD_INPUT_FIELDS):
|
||||
raise AnalyzerError("reference FMOD input profile is incomplete")
|
||||
source_slots = (9, 10, 12, 18) if source_layout == 2 else (8, 9, 10, 16)
|
||||
target_slots = (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)
|
||||
for field, old_slot, new_slot, length in zip(
|
||||
input_fields, source_slots, target_slots, (4, 16, 12, 16)
|
||||
):
|
||||
source_slots = fmod_vtable_slots(source_layout, True)
|
||||
target_slots = fmod_vtable_slots(layout, True)
|
||||
for field, length in zip(FMOD_INPUT_FIELDS, (4, 16, 12, 16)):
|
||||
old_slot, new_slot = source_slots[field], target_slots[field]
|
||||
if reference_relocations.get(source_vtable + old_slot * 8) != source[field]:
|
||||
raise AnalyzerError("reference FMOD input slot changed")
|
||||
target = relocations.get(vtable + new_slot * 8)
|
||||
@@ -1541,6 +1608,7 @@ def derive_runtime_compatibility(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
manifests: Sequence[Path],
|
||||
fallback: ElfImage | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source = load_reference_runtime_profile(manifests, reference.build_id)
|
||||
result: dict[str, Any] = {}
|
||||
@@ -1563,9 +1631,38 @@ def derive_runtime_compatibility(
|
||||
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
|
||||
reference, candidate, fmod
|
||||
)
|
||||
elif fallback is not None:
|
||||
bridge = derive_fallback_fmod_output_device_bridge(
|
||||
fallback, candidate, manifests
|
||||
)
|
||||
if bridge is not None:
|
||||
result["fmod_output_device_bridge"] = bridge
|
||||
return result
|
||||
|
||||
|
||||
def derive_fallback_fmod_output_device_bridge(
|
||||
fallback: ElfImage,
|
||||
candidate: ElfImage,
|
||||
manifests: Sequence[Path],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Derive the optional FMOD bridge from another build in the manifests.
|
||||
|
||||
Any failure leaves the bridge out.
|
||||
"""
|
||||
if fallback.build_id == candidate.build_id:
|
||||
return None
|
||||
try:
|
||||
fallback.validate_toolchain()
|
||||
fmod = load_reference_runtime_profile(manifests, fallback.build_id).get(
|
||||
"fmod_output_device_bridge"
|
||||
)
|
||||
if fmod is None:
|
||||
return None
|
||||
return derive_fmod_output_device_bridge(fallback, candidate, fmod)
|
||||
except AnalyzerError:
|
||||
return None
|
||||
|
||||
|
||||
def find_registry_function_entry(
|
||||
candidate: ElfImage,
|
||||
body_rva: int,
|
||||
@@ -1613,6 +1710,73 @@ def find_registry_function_entry(
|
||||
return entries[0]
|
||||
|
||||
|
||||
def find_renamed_registry_body_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
reference_body: Sequence[Any],
|
||||
minimum_anchor_bytes: int,
|
||||
) -> SignatureMatch:
|
||||
"""Find the registry initializer body under other temporary registers.
|
||||
|
||||
The body allocates the registry, clears it, and starts filling it:
|
||||
|
||||
0 mov edi, SIZE 6 xor esi, esi
|
||||
1 call allocate 7 call memset
|
||||
2 mov rbx, rax 8 mov ONE, 0x3f800000
|
||||
3 xor ZERO, ZERO 9 mov dword ptr [rbx + 0x20], ONE
|
||||
4 mov edx, SIZE 10 lea FIELD, [rbx + 0x28]
|
||||
5 mov rdi, rax
|
||||
|
||||
ZERO, ONE and FIELD are the compiler's choice. Instructions 4 to 7 use
|
||||
registers the calling convention fixes, so they are searched for by bytes
|
||||
and every hit is then checked against all eleven instructions.
|
||||
"""
|
||||
clear_index = 4
|
||||
clear_matches = find_signature_matches(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"registry-initializer-clear",
|
||||
reference_body[clear_index].address,
|
||||
4,
|
||||
True,
|
||||
minimum_anchor_bytes,
|
||||
(0, 0),
|
||||
),
|
||||
allow_multiple_candidates=True,
|
||||
)
|
||||
body_spec = SignatureSpec(
|
||||
"registry-initializer-body",
|
||||
reference_body[0].address,
|
||||
len(reference_body),
|
||||
True,
|
||||
minimum_anchor_bytes,
|
||||
(0, 4),
|
||||
(3, 8, 9, 10),
|
||||
)
|
||||
# Instructions 0 to 3 keep their lengths as long as ZERO stays one of r8
|
||||
# to r15, so the body starts this far before the clear sequence.
|
||||
lead = reference_body[clear_index].address - reference_body[0].address
|
||||
found = []
|
||||
for clear in clear_matches:
|
||||
if clear.rva < lead:
|
||||
continue
|
||||
try:
|
||||
body = disassemble(candidate, clear.rva - lead, len(reference_body))
|
||||
if body[clear_index].address != clear.rva:
|
||||
continue
|
||||
validate_semantic_match(reference_body, body, body_spec)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
found.append(SignatureMatch(clear.rva - lead, tuple(reference_body), body))
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(
|
||||
f"signature registry-initializer-body matched {len(found)} "
|
||||
"candidate locations with renamed registers"
|
||||
)
|
||||
return found[0]
|
||||
|
||||
|
||||
def find_registry_signature_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
@@ -1633,7 +1797,18 @@ def find_registry_signature_match(
|
||||
spec.minimum_anchor_bytes,
|
||||
(0, 4),
|
||||
)
|
||||
body_match = find_signature_match(reference, candidate, body_spec)
|
||||
try:
|
||||
body_match = find_signature_match(reference, candidate, body_spec)
|
||||
except AnalyzerError:
|
||||
body_match = find_renamed_registry_body_match(
|
||||
reference,
|
||||
candidate,
|
||||
reference_full[
|
||||
body_instruction_index : body_instruction_index
|
||||
+ body_instruction_count
|
||||
],
|
||||
spec.minimum_anchor_bytes,
|
||||
)
|
||||
candidate_entry = find_registry_function_entry(
|
||||
candidate,
|
||||
body_match.rva,
|
||||
@@ -1671,14 +1846,14 @@ def find_constructor_boundary_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
reference_entry: int,
|
||||
candidate_entry: int,
|
||||
candidate_entries: Sequence[int],
|
||||
) -> SignatureMatch:
|
||||
if reference_entry != spec.reference_rva:
|
||||
raise AnalyzerError(
|
||||
"reference constructor boundary moved outside init-array index 4"
|
||||
)
|
||||
reference_wrapper = disassemble(reference, reference_entry, 1)
|
||||
"""Match a constructor that is one jump to its body.
|
||||
|
||||
The body is found by signature, and the candidate constructor is the one
|
||||
of candidate_entries that jumps to it.
|
||||
"""
|
||||
reference_wrapper = disassemble(reference, spec.reference_rva, 1)
|
||||
reference_target = direct_jump_target(
|
||||
reference_wrapper[0], "reference constructor boundary"
|
||||
)
|
||||
@@ -1686,20 +1861,29 @@ def find_constructor_boundary_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"constructor-4-target",
|
||||
f"{spec.name}-target",
|
||||
reference_target,
|
||||
spec.instruction_count,
|
||||
),
|
||||
)
|
||||
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
||||
candidate_target = direct_jump_target(
|
||||
candidate_wrapper[0], "candidate constructor boundary"
|
||||
)
|
||||
if candidate_target != target_match.rva:
|
||||
found = []
|
||||
for candidate_entry in candidate_entries:
|
||||
try:
|
||||
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
||||
candidate_target = direct_jump_target(
|
||||
candidate_wrapper[0], "candidate constructor boundary"
|
||||
)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if candidate_target == target_match.rva:
|
||||
found.append(
|
||||
SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
||||
)
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(
|
||||
"constructor index 4 no longer targets its verified boundary"
|
||||
f"{spec.name} no longer targets its verified boundary"
|
||||
)
|
||||
return SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
||||
return found[0]
|
||||
|
||||
|
||||
def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||
@@ -1713,6 +1897,161 @@ def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||
return tuple(targets)
|
||||
|
||||
|
||||
def is_register_operand(operand: Any, register: int) -> bool:
|
||||
return operand.type == capstone_x86.X86_OP_REG and operand.reg == register
|
||||
|
||||
|
||||
def jni_singleton_publication(image: ElfImage, entry: int) -> tuple[Any, ...]:
|
||||
"""Return the instructions that publish the JNI singleton.
|
||||
|
||||
They open its initializer at entry, after a stack frame if it has one:
|
||||
|
||||
lea rax, [rip + OBJECT]
|
||||
xchg qword ptr [rip + SLOT], rax
|
||||
mov qword ptr [rip + OBJECT], rax
|
||||
|
||||
OBJECT is the eight bytes after SLOT.
|
||||
"""
|
||||
for frame in (0, 2):
|
||||
try:
|
||||
decoded = disassemble(image, entry, frame + 3)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if frame:
|
||||
push, link = decoded[:2]
|
||||
if (
|
||||
push.mnemonic != "push"
|
||||
or len(push.operands) != 1
|
||||
or not is_register_operand(push.operands[0], capstone_x86.X86_REG_RBP)
|
||||
or link.mnemonic != "mov"
|
||||
or len(link.operands) != 2
|
||||
or not is_register_operand(link.operands[0], capstone_x86.X86_REG_RBP)
|
||||
or not is_register_operand(link.operands[1], capstone_x86.X86_REG_RSP)
|
||||
):
|
||||
continue
|
||||
load, exchange, store = decoded[frame:]
|
||||
object_targets = rip_targets(load)
|
||||
slot_targets = rip_targets(exchange)
|
||||
stored_targets = rip_targets(store)
|
||||
if (
|
||||
load.mnemonic == "lea"
|
||||
and len(load.operands) == 2
|
||||
and is_register_operand(load.operands[0], capstone_x86.X86_REG_RAX)
|
||||
and exchange.mnemonic == "xchg"
|
||||
and len(exchange.operands) == 2
|
||||
and exchange.operands[0].size == 8
|
||||
and is_register_operand(exchange.operands[1], capstone_x86.X86_REG_RAX)
|
||||
and store.mnemonic == "mov"
|
||||
and len(store.operands) == 2
|
||||
and store.operands[0].size == 8
|
||||
and is_register_operand(store.operands[1], capstone_x86.X86_REG_RAX)
|
||||
and len(object_targets) == 1
|
||||
and len(slot_targets) == 1
|
||||
and len(stored_targets) == 1
|
||||
and object_targets[0] == stored_targets[0]
|
||||
and object_targets[0] == slot_targets[0] + 8
|
||||
):
|
||||
return decoded[frame:]
|
||||
raise AnalyzerError(
|
||||
f"JNI singleton initializer at {format_rva(entry)} "
|
||||
"does not publish its singleton"
|
||||
)
|
||||
|
||||
|
||||
def find_jni_singleton_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
candidate_entries: Sequence[int],
|
||||
) -> SignatureMatch:
|
||||
"""Find the JNI singleton initializer among candidate_entries.
|
||||
|
||||
The instructions after the publication follow the singleton's layout, so
|
||||
when a release changes that layout only the publication is matched. The
|
||||
initializer itself runs from the init-array either way.
|
||||
"""
|
||||
try:
|
||||
return find_signature_match(reference, candidate, spec)
|
||||
except AnalyzerError:
|
||||
pass
|
||||
reference_publication = jni_singleton_publication(reference, spec.reference_rva)
|
||||
found = find_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"jni-singleton-publication",
|
||||
reference_publication[0].address,
|
||||
len(reference_publication),
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
)
|
||||
for entry in candidate_entries:
|
||||
try:
|
||||
publication = jni_singleton_publication(candidate, entry)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if publication[0].address == found.rva:
|
||||
return SignatureMatch(entry, reference_publication, publication)
|
||||
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
|
||||
|
||||
|
||||
# The native replay starts at this init-array index on x86_64.
|
||||
LEADING_CONSTRUCTOR_BEGIN = 2
|
||||
# The process-load constructor, the JNI singleton initializer and two wiring
|
||||
# entries. A release may put them in any order.
|
||||
LEADING_CONSTRUCTOR_COUNT = 4
|
||||
|
||||
|
||||
def match_leading_constructors(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
constructor_anchors: dict[str, Any],
|
||||
jni_spec: SignatureSpec,
|
||||
reference_init: Sequence[int],
|
||||
candidate_init: Sequence[int],
|
||||
) -> tuple[tuple[int, ...], SignatureMatch]:
|
||||
"""Find each reference leading constructor in the candidate by its code.
|
||||
|
||||
Returns, for each reference leading constructor, the candidate init-array
|
||||
index of the same constructor, both counted from
|
||||
LEADING_CONSTRUCTOR_BEGIN, and the JNI singleton initializer match.
|
||||
"""
|
||||
moved = "constructor moved outside verified init-array boundary"
|
||||
end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||
if len(reference_init) < end or len(candidate_init) < end:
|
||||
raise AnalyzerError("init-array shape is not derivable")
|
||||
candidate_entries = tuple(candidate_init[LEADING_CONSTRUCTOR_BEGIN:end])
|
||||
candidate_offsets = []
|
||||
jni_match = None
|
||||
for index in range(LEADING_CONSTRUCTOR_BEGIN, end):
|
||||
reference_entry = reference_init[index]
|
||||
name = f"constructor-{index}"
|
||||
if parse_rva(constructor_anchors.get(str(index)), name) != reference_entry:
|
||||
raise AnalyzerError(moved)
|
||||
if reference_entry == jni_spec.reference_rva:
|
||||
match = find_jni_singleton_match(
|
||||
reference, candidate, jni_spec, candidate_entries
|
||||
)
|
||||
jni_match = match
|
||||
else:
|
||||
spec = SignatureSpec(name, reference_entry, 24)
|
||||
if disassemble(reference, reference_entry, 1)[0].mnemonic == "jmp":
|
||||
match = find_constructor_boundary_match(
|
||||
reference, candidate, spec, candidate_entries
|
||||
)
|
||||
else:
|
||||
match = find_signature_match(reference, candidate, spec)
|
||||
if match.rva not in candidate_entries:
|
||||
raise AnalyzerError(moved)
|
||||
candidate_offset = candidate_entries.index(match.rva)
|
||||
if candidate_offset in candidate_offsets:
|
||||
raise AnalyzerError(moved)
|
||||
candidate_offsets.append(candidate_offset)
|
||||
if jni_match is None:
|
||||
raise AnalyzerError("JNI singleton initializer is not a leading constructor")
|
||||
return tuple(candidate_offsets), jni_match
|
||||
|
||||
|
||||
def mapped_rip_target(
|
||||
match: SignatureMatch, reference_target: int, description: str
|
||||
) -> int:
|
||||
@@ -1805,7 +2144,6 @@ def create_signature_specs(
|
||||
bridges = bridge_map(profile)
|
||||
data_seeds = profile["data_seeds"]
|
||||
bootstrap = profile["native_pre_jni_bootstrap"]
|
||||
constructor_rvas = anchors["constructor_rvas"]
|
||||
return [
|
||||
SignatureSpec(
|
||||
"small-allocate",
|
||||
@@ -1870,45 +2208,60 @@ def create_signature_specs(
|
||||
parse_rva(anchors["jni_singleton_initializer_rva"], "JNI singleton anchor"),
|
||||
11,
|
||||
),
|
||||
SignatureSpec(
|
||||
"constructor-2", parse_rva(constructor_rvas["2"], "constructor 2"), 24
|
||||
),
|
||||
SignatureSpec(
|
||||
"constructor-3", parse_rva(constructor_rvas["3"], "constructor 3"), 24
|
||||
),
|
||||
SignatureSpec(
|
||||
"constructor-4",
|
||||
parse_rva(constructor_rvas["4"], "constructor 4"),
|
||||
24,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def adjusted_ranges(
|
||||
ranges: list[dict[str, int]], reference_count: int, candidate_count: int
|
||||
def remapped_constructor_ranges(
|
||||
ranges: list[dict[str, int]],
|
||||
candidate_offsets: Sequence[int],
|
||||
reference_count: int,
|
||||
candidate_count: int,
|
||||
) -> list[dict[str, int]]:
|
||||
result = []
|
||||
"""Rewrite the reference constructor ranges for the candidate init-array.
|
||||
|
||||
Each leading constructor keeps its place in or out of the replay under
|
||||
its candidate index, and every later constructor runs.
|
||||
"""
|
||||
cannot_fit = "reference constructor range cannot fit candidate init-array"
|
||||
leading_end = LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||
ranges = validated_ranges(ranges, "reference constructor ranges", reference_count)
|
||||
if (
|
||||
ranges[0]["begin"] < LEADING_CONSTRUCTOR_BEGIN
|
||||
or ranges[-1]["begin"] > leading_end
|
||||
or ranges[-1]["end_exclusive"] != reference_count
|
||||
or reference_count <= leading_end
|
||||
or candidate_count <= leading_end
|
||||
):
|
||||
raise AnalyzerError(cannot_fit)
|
||||
runs = [False] * LEADING_CONSTRUCTOR_COUNT
|
||||
for item in ranges:
|
||||
end = (
|
||||
candidate_count
|
||||
if item["end_exclusive"] == reference_count
|
||||
else item["end_exclusive"]
|
||||
)
|
||||
if end > candidate_count:
|
||||
raise AnalyzerError(
|
||||
"reference constructor range cannot fit candidate init-array"
|
||||
)
|
||||
result.append({"begin": item["begin"], "end_exclusive": end})
|
||||
for index in range(item["begin"], min(item["end_exclusive"], leading_end)):
|
||||
runs[candidate_offsets[index - LEADING_CONSTRUCTOR_BEGIN]] = True
|
||||
result = []
|
||||
run_begin = None
|
||||
for offset, run in enumerate(runs):
|
||||
index = LEADING_CONSTRUCTOR_BEGIN + offset
|
||||
if run:
|
||||
if run_begin is None:
|
||||
run_begin = index
|
||||
elif run_begin is not None:
|
||||
result.append({"begin": run_begin, "end_exclusive": index})
|
||||
run_begin = None
|
||||
result.append(
|
||||
{
|
||||
"begin": leading_end if run_begin is None else run_begin,
|
||||
"end_exclusive": candidate_count,
|
||||
}
|
||||
)
|
||||
return validated_ranges(result, "derived constructor ranges", candidate_count)
|
||||
|
||||
|
||||
def derive_profile(
|
||||
def check_derivation_inputs(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
reference_sidecar: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
require_capstone()
|
||||
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
||||
"""Rejects inputs no derivation may use; returns both init-arrays."""
|
||||
if (
|
||||
reference.build_id != reference_sidecar["elf_build_id"]
|
||||
or reference.sha256 != reference_sidecar["payload_sha256"]
|
||||
@@ -1926,17 +2279,28 @@ def derive_profile(
|
||||
"candidate is missing required JNI exports: "
|
||||
+ ", ".join(sorted(missing_exports))
|
||||
)
|
||||
|
||||
reference_profile = reference_sidecar["profile"]
|
||||
anchors = reference_sidecar["derivation_anchors"]
|
||||
reference_init = reference.init_array_relocations()
|
||||
candidate_init = candidate.init_array_relocations()
|
||||
if len(reference_init) != reference_profile["init_array_count"]:
|
||||
if len(reference_init) != reference_sidecar["profile"]["init_array_count"]:
|
||||
raise AnalyzerError(
|
||||
"reference sidecar init-array count differs from ELF metadata"
|
||||
)
|
||||
if len(candidate_init) < 8:
|
||||
raise AnalyzerError("candidate init-array is unexpectedly short")
|
||||
return reference_init, candidate_init
|
||||
|
||||
|
||||
def derive_profile(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
reference_sidecar: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
require_capstone()
|
||||
reference_init, candidate_init = check_derivation_inputs(
|
||||
reference, candidate, reference_sidecar
|
||||
)
|
||||
reference_profile = reference_sidecar["profile"]
|
||||
anchors = reference_sidecar["derivation_anchors"]
|
||||
matches = {}
|
||||
allocate_matches = ()
|
||||
for spec in create_signature_specs(reference_profile, anchors):
|
||||
@@ -1948,13 +2312,14 @@ def derive_profile(
|
||||
matches[spec.name] = find_registry_signature_match(
|
||||
reference, candidate, spec
|
||||
)
|
||||
elif spec.name == "constructor-4":
|
||||
matches[spec.name] = find_constructor_boundary_match(
|
||||
elif spec.name == "jni-singleton-initializer":
|
||||
leading_offsets, matches[spec.name] = match_leading_constructors(
|
||||
reference,
|
||||
candidate,
|
||||
anchors["constructor_rvas"],
|
||||
spec,
|
||||
reference_init[4],
|
||||
candidate_init[4],
|
||||
reference_init,
|
||||
candidate_init,
|
||||
)
|
||||
else:
|
||||
matches[spec.name] = find_signature_match(reference, candidate, spec)
|
||||
@@ -1990,21 +2355,7 @@ def derive_profile(
|
||||
"registry allocator call"
|
||||
)
|
||||
matches["allocate"] = selected_allocate[0]
|
||||
for index in (2, 3, 4):
|
||||
match = matches[f"constructor-{index}"]
|
||||
if (
|
||||
reference_init[index] != match.reference_instructions[0].address
|
||||
or candidate_init[index] != match.rva
|
||||
):
|
||||
raise AnalyzerError(
|
||||
f"constructor {index} moved outside its verified init-array boundary"
|
||||
)
|
||||
singleton_match = matches["jni-singleton-initializer"]
|
||||
if (
|
||||
reference_init[5] != singleton_match.reference_instructions[0].address
|
||||
or candidate_init[5] != singleton_match.rva
|
||||
):
|
||||
raise AnalyzerError("JNI singleton initializer is not constructor index 5")
|
||||
|
||||
candidate_bridge_entries = []
|
||||
for entry in reference_profile["bridge_entries"]:
|
||||
@@ -2075,28 +2426,34 @@ def derive_profile(
|
||||
init_section = candidate.sections[".init_array"]
|
||||
reference_count = reference_profile["init_array_count"]
|
||||
candidate_count = len(candidate_init)
|
||||
constructor_ranges = adjusted_ranges(
|
||||
validated_ranges(
|
||||
reference_profile["constructor_run_ranges"],
|
||||
"reference constructor ranges",
|
||||
reference_count,
|
||||
),
|
||||
constructor_ranges = remapped_constructor_ranges(
|
||||
reference_profile["constructor_run_ranges"],
|
||||
leading_offsets,
|
||||
reference_count,
|
||||
candidate_count,
|
||||
)
|
||||
native_ranges = adjusted_ranges(
|
||||
validated_ranges(
|
||||
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||
"reference native constructor ranges",
|
||||
reference_count,
|
||||
),
|
||||
native_ranges = remapped_constructor_ranges(
|
||||
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||
leading_offsets,
|
||||
reference_count,
|
||||
candidate_count,
|
||||
)
|
||||
thread_boundary = reference_profile[
|
||||
reference_thread_boundary = reference_profile[
|
||||
"native_mimalloc_thread_initializer_after_constructor"
|
||||
]
|
||||
if thread_boundary != 2 or not any(
|
||||
if not (
|
||||
LEADING_CONSTRUCTOR_BEGIN
|
||||
<= reference_thread_boundary
|
||||
< LEADING_CONSTRUCTOR_BEGIN + LEADING_CONSTRUCTOR_COUNT
|
||||
):
|
||||
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||
# The thread initializer follows the process-load constructor to its
|
||||
# candidate index.
|
||||
thread_boundary = (
|
||||
LEADING_CONSTRUCTOR_BEGIN
|
||||
+ leading_offsets[reference_thread_boundary - LEADING_CONSTRUCTOR_BEGIN]
|
||||
)
|
||||
if not any(
|
||||
item["begin"] <= thread_boundary < item["end_exclusive"]
|
||||
for item in native_ranges
|
||||
):
|
||||
@@ -2243,13 +2600,22 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
|
||||
metadata_path = Path(arguments.payload_metadata)
|
||||
sidecar_path = Path(arguments.reference_profile)
|
||||
reference_sidecar = validated_sidecar(sidecar_path)
|
||||
with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate:
|
||||
with contextlib.ExitStack() as stack:
|
||||
reference = stack.enter_context(ElfImage(reference_path))
|
||||
candidate = stack.enter_context(ElfImage(candidate_path))
|
||||
fallback = None
|
||||
if arguments.runtime_fallback_lib is not None:
|
||||
with contextlib.suppress(AnalyzerError):
|
||||
fallback = stack.enter_context(
|
||||
ElfImage(Path(arguments.runtime_fallback_lib))
|
||||
)
|
||||
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
|
||||
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
|
||||
runtime_compatibility = derive_runtime_compatibility(
|
||||
reference,
|
||||
candidate,
|
||||
tuple(Path(path) for path in arguments.reference_compatibility),
|
||||
fallback,
|
||||
)
|
||||
return output_documents(
|
||||
candidate,
|
||||
@@ -2274,6 +2640,12 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
|
||||
metavar="MANIFEST",
|
||||
help="compatibility manifest containing exact reference runtime anchors",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runtime-fallback-lib",
|
||||
metavar="LIB",
|
||||
help="libroblox.so of another build in the compatibility manifests; "
|
||||
"supplies the FMOD output-device anchors when the reference has none",
|
||||
)
|
||||
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
||||
parser.add_argument("--payload-metadata", required=True, metavar="META")
|
||||
parser.add_argument("--output", required=True, metavar="PROFILE")
|
||||
|
||||
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user