mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 22: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 | ||
|
|
9a1ccbb5ce | ||
|
|
ecb80b3ee1 | ||
|
|
6362283ade | ||
|
|
fbf97df708 | ||
|
|
25cfd94c56 | ||
|
|
3c20ee3c55 | ||
|
|
5e24bee4ab | ||
|
|
a014751cd4 | ||
|
|
7e057232e4 | ||
|
|
f5080e8a99 | ||
|
|
3d89f128ce | ||
|
|
b187efbedf | ||
|
|
dd23f06a8b |
No files matched your search
@@ -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
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
bash tests/anylinux_dependencies_test.sh .
|
||||
|
||||
native-build:
|
||||
name: Native build (${{ matrix.arch }})
|
||||
name: Native build (${{ matrix.arch }}, VR ${{ matrix.vr }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -86,8 +86,15 @@ jobs:
|
||||
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
|
||||
@@ -112,7 +119,8 @@ jobs:
|
||||
run: |
|
||||
test "$(uname -m)" = "${{ matrix.arch }}"
|
||||
cmake -S . -B build-ci -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
|
||||
-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
|
||||
|
||||
@@ -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
|
||||
+238
-205
@@ -1,248 +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.
|
||||
- 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.
|
||||
- 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.
|
||||
- The Flatpak build now detects an NVIDIA driver from inside its sandbox, so it
|
||||
uses X11 instead of native Wayland where both are available. This avoids
|
||||
Wayland hangs and display disconnects.
|
||||
- `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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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
|
||||
|
||||
@@ -250,37 +288,32 @@ 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.3...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
|
||||
|
||||
+8
-46
@@ -9,7 +9,7 @@
|
||||
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(Mocktail
|
||||
VERSION 1.0.4
|
||||
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.
|
||||
@@ -252,6 +254,7 @@ add_library(mocktail_legacy_jnivm STATIC
|
||||
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)
|
||||
@@ -278,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
|
||||
@@ -295,9 +299,11 @@ add_library(mocktail_runtime STATIC
|
||||
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
|
||||
@@ -561,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
|
||||
@@ -629,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}\""
|
||||
@@ -642,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
|
||||
|
||||
@@ -56,15 +56,14 @@ 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
|
||||
@@ -73,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.
|
||||
@@ -7,13 +7,16 @@
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -26,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.
|
||||
|
||||
@@ -79,8 +84,17 @@ 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).
|
||||
@@ -108,8 +122,6 @@ removing the marker.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## FFlag overrides
|
||||
@@ -121,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",
|
||||
@@ -133,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
|
||||
@@ -215,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.
|
||||
|
||||
@@ -102,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": {
|
||||
|
||||
@@ -68,6 +68,12 @@ 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,20 +22,6 @@ what you are playing and you can switch it all off in one click.
|
||||
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.
|
||||
|
||||
## Shipped
|
||||
|
||||
- Lower input latency: frames are presented as soon as they are ready.
|
||||
|
||||
+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
|
||||
```
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -152,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#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"
|
||||
@@ -260,6 +261,7 @@ class RobloxExperienceComposition final {
|
||||
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_;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_HAPTIC_PROTOCOL_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_HAPTIC_PROTOCOL_BRIDGE_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
using HapticSinkFn = std::function<void(int hand, float amplitude,
|
||||
uint64_t duration_ns, float frequency_hz)>;
|
||||
using HapticStopFn = std::function<void(int hand)>;
|
||||
using HapticStateSyncFn = std::function<void(JNIEnv* env)>;
|
||||
|
||||
class HapticWaveformPlayer;
|
||||
|
||||
class RobloxHapticProtocolBridge final {
|
||||
public:
|
||||
RobloxHapticProtocolBridge(
|
||||
JniEnvironmentProvider environment,
|
||||
RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects,
|
||||
HapticSinkFn haptic_sink = nullptr,
|
||||
HapticStopFn haptic_stop = nullptr,
|
||||
HapticStateSyncFn haptic_state_sync = nullptr);
|
||||
~RobloxHapticProtocolBridge();
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
static std::string GetMotorsJson();
|
||||
// True during a VR session; main() sets MOCKTAIL_VR_ENABLED to 0 when VR
|
||||
// cannot start, so the bridge stays off for desktop play.
|
||||
static bool EnabledForThisSession();
|
||||
|
||||
private:
|
||||
std::shared_ptr<HapticWaveformPlayer> player_;
|
||||
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
#endif
|
||||
@@ -89,6 +89,12 @@ struct RobloxTextDisplaySink {
|
||||
UpdateFn update = nullptr;
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -32,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.
|
||||
@@ -115,6 +118,14 @@ 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,
|
||||
@@ -167,6 +178,9 @@ struct RobloxWebViewMessageBusSymbols {
|
||||
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 &&
|
||||
@@ -270,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;
|
||||
@@ -283,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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--talk-name=com.feralinteractive.GameMode",
|
||||
|
||||
@@ -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,19 @@
|
||||
<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>
|
||||
@@ -67,7 +76,6 @@
|
||||
<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>
|
||||
<li>The Flatpak build on NVIDIA now uses X11 to avoid Wayland hangs.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Executable
+1609
File diff suppressed because it is too large.
Load diff
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate src/vr/vr_shaders_spirv.h from shaders/vr with glslc.
|
||||
|
||||
The header is checked in so a build never needs a shader compiler. The SMAA
|
||||
passes include SMAA.hlsl from the third_party/smaa submodule. Run this
|
||||
after editing a shader; VrShaders.SpirvMatchesSources checks it when glslc is
|
||||
installed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
SHADERS = [
|
||||
("kFullscreenVertexSpirv", "shaders/vr/fullscreen.vert"),
|
||||
("kFxaaFragmentSpirv", "shaders/vr/fxaa.frag"),
|
||||
("kSmaaEdgesVertexSpirv", "shaders/vr/smaa_edges.vert"),
|
||||
("kSmaaEdgesFragmentSpirv", "shaders/vr/smaa_edges.frag"),
|
||||
("kSmaaWeightsVertexSpirv", "shaders/vr/smaa_weights.vert"),
|
||||
("kSmaaWeightsFragmentSpirv", "shaders/vr/smaa_weights.frag"),
|
||||
("kSmaaBlendVertexSpirv", "shaders/vr/smaa_blend.vert"),
|
||||
("kSmaaBlendFragmentSpirv", "shaders/vr/smaa_blend.frag"),
|
||||
]
|
||||
# SMAA.hlsl comes from the third_party/smaa submodule.
|
||||
INCLUDES = [ROOT / "third_party/smaa"]
|
||||
OUTPUT = ROOT / "src/vr/vr_shaders_spirv.h"
|
||||
|
||||
|
||||
def compile_words(glslc, source):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = pathlib.Path(directory) / "shader.spv"
|
||||
subprocess.run(
|
||||
[glslc, "-O", "--target-env=vulkan1.0"] +
|
||||
["-I" + str(path) for path in INCLUDES] +
|
||||
[str(ROOT / source), "-o", str(output)],
|
||||
check=True)
|
||||
data = output.read_bytes()
|
||||
return struct.unpack("<%dI" % (len(data) // 4), data)
|
||||
|
||||
|
||||
def render(glslc):
|
||||
lines = [
|
||||
"// Generated by scripts/generate_vr_shaders.py from shaders/vr. Do not edit.",
|
||||
"#ifndef MOCKTAIL_VR_VR_SHADERS_SPIRV_H_",
|
||||
"#define MOCKTAIL_VR_VR_SHADERS_SPIRV_H_",
|
||||
"",
|
||||
"#include <cstdint>",
|
||||
"",
|
||||
"namespace mocktail::vr {",
|
||||
"",
|
||||
]
|
||||
for name, source in SHADERS:
|
||||
words = compile_words(glslc, source)
|
||||
lines.append("// %s" % source)
|
||||
lines.append("inline constexpr std::uint32_t %s[] = {" % name)
|
||||
for start in range(0, len(words), 6):
|
||||
chunk = ", ".join("0x%08x" % word for word in words[start:start + 6])
|
||||
lines.append(" %s," % chunk)
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
lines += ["} // namespace mocktail::vr", "",
|
||||
"#endif // MOCKTAIL_VR_VR_SHADERS_SPIRV_H_", ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--glslc", default="glslc")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="exit 1 when the header is out of date")
|
||||
arguments = parser.parse_args()
|
||||
text = render(arguments.glslc)
|
||||
if arguments.check:
|
||||
current = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||
if current != text:
|
||||
print("%s is out of date; run %s" % (OUTPUT, __file__),
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
OUTPUT.write_text(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -107,6 +107,28 @@ AnyLinuxDownloadDependencies() {
|
||||
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
|
||||
}
|
||||
|
||||
# Prints the package name and path of each archive that is older than the
|
||||
# installed package.
|
||||
AnyLinuxDependencyDowngrades() {
|
||||
local package name version installed
|
||||
for package in "$@"; do
|
||||
read -r name version < <(pacman -Qp -- "${package}")
|
||||
installed="$(pacman -Q -- "${name}" 2>/dev/null | cut -d ' ' -f 2)" || continue
|
||||
[[ -n "${installed}" ]] || continue
|
||||
if (( $(vercmp "${version}" "${installed}") < 0 )); then
|
||||
printf '%s\t%s\n' "${name}" "${package}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Prints each pkg-config module whose requirements are not met.
|
||||
AnyLinuxDependencyBrokenModules() {
|
||||
local module
|
||||
while read -r module; do
|
||||
pkg-config --exists "${module}" 2>/dev/null || printf '%s\n' "${module}"
|
||||
done < <(pkg-config --list-package-names 2>/dev/null)
|
||||
}
|
||||
|
||||
AnyLinuxDependenciesMain() (
|
||||
AnyLinuxDependencyRequireContainer
|
||||
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
||||
@@ -126,7 +148,35 @@ AnyLinuxDependenciesMain() (
|
||||
while IFS=$'\t' read -r digest filename url; do
|
||||
packages+=("${DOWNLOADS}/${filename}")
|
||||
done <"${DOWNLOADS}/dependencies.tsv"
|
||||
pacman -U --noconfirm --needed "${packages[@]}"
|
||||
local broken name package
|
||||
local -a current=() older=() older_names=()
|
||||
local -A is_older=()
|
||||
while IFS=$'\t' read -r name package; do
|
||||
older_names+=("${name}")
|
||||
is_older["${package}"]=1
|
||||
done < <(AnyLinuxDependencyDowngrades "${packages[@]}")
|
||||
for package in "${packages[@]}"; do
|
||||
if [[ -n "${is_older[${package}]:-}" ]]; then
|
||||
older+=("${package}")
|
||||
else
|
||||
current+=("${package}")
|
||||
fi
|
||||
done
|
||||
(( ${#current[@]} == 0 )) || pacman -U --noconfirm --needed "${current[@]}"
|
||||
(( ${#older[@]} > 0 )) || return 0
|
||||
# A reduced package can lag the distribution. If the older version leaves
|
||||
# another package's pkg-config requirements unmet, build with the
|
||||
# distribution's package for that library. Modules that the reduced
|
||||
# packages of the current version already leave unmet do not count.
|
||||
broken="$(AnyLinuxDependencyBrokenModules)"
|
||||
pacman -U --noconfirm --needed "${older[@]}"
|
||||
if [[ "$(AnyLinuxDependencyBrokenModules)" != "${broken}" ]]; then
|
||||
printf '[anylinux-dependencies] reduced packages are too old; restoring %s\n' \
|
||||
"${older_names[*]}" >&2
|
||||
pacman -S --noconfirm "${older_names[@]}"
|
||||
[[ "$(AnyLinuxDependencyBrokenModules)" == "${broken}" ]] ||
|
||||
AnyLinuxDependencyDie 'reduced packages break pkg-config requirements'
|
||||
fi
|
||||
)
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
|
||||
@@ -207,7 +207,13 @@ EOF
|
||||
"${ANYLINUX_TOOL}" --make-appimage
|
||||
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
|
||||
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
|
||||
if [[ -n "${UPINFO:-}" ]]; then
|
||||
[[ -s "${result}.zsync" ]] || AnyLinuxDie "update information was set but no .zsync was generated"
|
||||
fi
|
||||
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
|
||||
if [[ -f "${result}.zsync" ]]; then
|
||||
mv -fT -- "${result}.zsync" "${ANYLINUX_OUTPUT}.zsync"
|
||||
fi
|
||||
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Run the HostAbi deriver and the solver on one update and compare them.
|
||||
|
||||
Usage:
|
||||
python3 roblox_abi_check.py --reference-lib OLD.so \\
|
||||
--reference-profile OLD_PROFILE.json \\
|
||||
--reference-compatibility config/roblox_compatibility.json \\
|
||||
--candidate-lib NEW.so
|
||||
|
||||
Prints every field where the two disagree. Exit status: 0 agree, 1 differ or
|
||||
one side failed, 2 both failed.
|
||||
|
||||
With --vr, the VR device bridge is compared too. `find_roblox_vr_offsets.py`
|
||||
finds it in each build on its own: its answer for the reference build is the
|
||||
solver's input, and its answer for the candidate joins the deriver's side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
def _load_sibling(name: str, file_name: str):
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
path = Path(__file__).resolve().with_name(file_name)
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError(f"cannot load {file_name}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
SOLVER = _load_sibling("mocktail_roblox_abi_solver", "roblox_abi_solver.py")
|
||||
FINDER = _load_sibling("mocktail_find_roblox_vr_offsets", "find_roblox_vr_offsets.py")
|
||||
DERIVER = SOLVER.DERIVER
|
||||
AnalyzerError = SOLVER.AnalyzerError
|
||||
format_rva = SOLVER.format_rva
|
||||
|
||||
# Where find_roblox_vr_offsets.py reports each VR bridge field.
|
||||
_FINDER_FIELDS = {
|
||||
"vtable_rva": ("vr_debug_device_bridge", "vtable_rva"),
|
||||
"constructor_rva": ("vr_debug_device_bridge", "constructor_rva"),
|
||||
"state_getter_rva": ("vr_debug_device_bridge", "state_getter_rva"),
|
||||
"eye_getter_rva": ("vr_debug_device_bridge", "eye_getter_rva"),
|
||||
"eye_initializer_rva": ("vr_debug_device_bridge", "eye_initializer_rva"),
|
||||
"emulator_flag_storage_rva": ("vr_debug_device_bridge", "emulator_flag_storage_rva"),
|
||||
"device_create_framebuffer_vtable_offset": (
|
||||
"vr_debug_device_bridge", "device_create_framebuffer_vtable_offset"
|
||||
),
|
||||
"state_memcpy_rva": ("vr_bridge_supplement", "state_getter_memcpy_rva"),
|
||||
"pointer_frame_rva": ("vr_bridge_supplement", "world_pointer_getter_rva"),
|
||||
"workspace_getter_rva": ("vr_bridge_supplement", "workspace_getter_rva"),
|
||||
"camera_vtable_offset": ("vr_bridge_supplement", "workspace_camera_vtable_offset"),
|
||||
"hand_pitch_storage_rva": ("vr_bridge_supplement", "new_vr_system_hand_pitch_storage_rva"),
|
||||
"haptic_sink_rva": ("vr_bridge_supplement", "haptic_sink_rva"),
|
||||
"pointer_hand_selector_offset": ("vr_bridge_supplement", "usercframe_selector_offset"),
|
||||
"camera_head_scale_offset": ("vr_bridge_supplement", "camera_head_scale_offset"),
|
||||
"keep_haptics_flag_rva": ("vr_bridge_supplement", "keep_haptics_flag_rva"),
|
||||
"vr_haptics_flag_rva": ("vr_bridge_supplement", "vr_haptics_flag_rva"),
|
||||
"support_generic_haptics_flag_rva": (
|
||||
"vr_bridge_supplement", "support_generic_haptics_flag_rva"
|
||||
),
|
||||
"fix_register_motors_flag_rva": ("vr_bridge_supplement", "fix_register_motors_flag_rva"),
|
||||
}
|
||||
|
||||
|
||||
def vr_bridge_from_finder(
|
||||
result: dict[str, Any], fields: Sequence[str] = SOLVER.VR.VR_FIELDS
|
||||
) -> dict[str, str]:
|
||||
"""The VR bridge fields from a find_roblox_vr_offsets.py result."""
|
||||
bridge = {}
|
||||
for field in fields:
|
||||
section, key = _FINDER_FIELDS[field]
|
||||
try:
|
||||
bridge[field] = result[section][key]
|
||||
except (KeyError, TypeError) as error:
|
||||
raise AnalyzerError(f"VR offset finder result lacks {key}") from error
|
||||
return bridge
|
||||
|
||||
|
||||
def _find_vr_bridge(library: Path) -> dict[str, str]:
|
||||
try:
|
||||
return vr_bridge_from_finder(FINDER.find_vr_offsets(library))
|
||||
except FINDER.AnalyzerError as error:
|
||||
raise AnalyzerError(f"VR offset finder: {error}") from error
|
||||
|
||||
|
||||
def compare_results(derived: Any, solved: Any, path: str = "") -> list[str]:
|
||||
"""Returns one line for every leaf value where the two results differ."""
|
||||
if isinstance(derived, dict) and isinstance(solved, dict):
|
||||
lines = []
|
||||
for key in sorted(derived.keys() | solved.keys(), key=str):
|
||||
child = f"{path}.{key}" if path else str(key)
|
||||
if key not in solved:
|
||||
lines.append(f"{child}: deriver {derived[key]}, solver missing")
|
||||
elif key not in derived:
|
||||
lines.append(f"{child}: deriver missing, solver {solved[key]}")
|
||||
else:
|
||||
lines.extend(compare_results(derived[key], solved[key], child))
|
||||
return lines
|
||||
if isinstance(derived, list) and isinstance(solved, list) and len(derived) == len(solved):
|
||||
return [
|
||||
line
|
||||
for index, (left, right) in enumerate(zip(derived, solved))
|
||||
for line in compare_results(left, right, f"{path}[{index}]")
|
||||
]
|
||||
return [] if derived == solved else [f"{path}: deriver {derived}, solver {solved}"]
|
||||
|
||||
|
||||
def check_status(
|
||||
derived: Any, solved: Any, deriver_error: str | None, solver_error: str | None
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Exit status and report lines: 0 agree, 1 differ or one failed, 2 both failed."""
|
||||
lines = []
|
||||
if deriver_error is not None:
|
||||
lines.append(f"deriver failed: {deriver_error}")
|
||||
if solver_error is not None:
|
||||
lines.append(f"solver failed: {solver_error}")
|
||||
if deriver_error is not None and solver_error is not None:
|
||||
return 2, lines
|
||||
if deriver_error is not None or solver_error is not None:
|
||||
return 1, lines
|
||||
differences = compare_results(derived, solved)
|
||||
if differences:
|
||||
return 1, differences
|
||||
return 0, ["deriver and solver agree on every field"]
|
||||
|
||||
|
||||
def _solver_runtime_source(source: dict[str, Any]) -> dict[str, Any]:
|
||||
# The deriver's loader returns integers; the solver reads hex strings.
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in source.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = {
|
||||
field: item if field == "vtable_layout_version" else format_rva(item)
|
||||
for field, item in value.items()
|
||||
}
|
||||
else:
|
||||
result[key] = format_rva(value)
|
||||
return result
|
||||
|
||||
|
||||
def _describe(error: Exception) -> str:
|
||||
if isinstance(error, AnalyzerError):
|
||||
return str(error)
|
||||
return f"{type(error).__name__}: {error}"
|
||||
|
||||
|
||||
def check(arguments: argparse.Namespace) -> int:
|
||||
"""Runs the deriver and the solver on one update and compares them."""
|
||||
sidecar = DERIVER.validated_sidecar(Path(arguments.reference_profile))
|
||||
manifests = [Path(path) for path in arguments.reference_compatibility]
|
||||
cache_dir = Path(arguments.cache_dir) if arguments.cache_dir else None
|
||||
derived = solved = deriver_error = solver_error = None
|
||||
reference_vr = _find_vr_bridge(Path(arguments.reference_lib)) if arguments.vr else None
|
||||
with DERIVER.ElfImage(Path(arguments.reference_lib)) as reference, DERIVER.ElfImage(
|
||||
Path(arguments.candidate_lib)
|
||||
) as candidate:
|
||||
try:
|
||||
profile, anchors = DERIVER.derive_profile(reference, candidate, sidecar)
|
||||
runtime = DERIVER.derive_runtime_compatibility(reference, candidate, manifests)
|
||||
derived = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
||||
if reference_vr is not None:
|
||||
derived["vr_device_bridge"] = _find_vr_bridge(Path(arguments.candidate_lib))
|
||||
except Exception as error:
|
||||
deriver_error = _describe(error)
|
||||
try:
|
||||
# The input checks are cheap; indexing both libraries is not.
|
||||
SOLVER.check_inputs(reference, candidate, sidecar)
|
||||
solver = SOLVER.Solver(reference, candidate, cache_dir)
|
||||
profile, anchors = solver.derive_profile(sidecar)
|
||||
source = DERIVER.load_reference_runtime_profile(manifests, reference.build_id)
|
||||
runtime = solver.derive_runtime_compatibility(_solver_runtime_source(source))
|
||||
solved = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
|
||||
if reference_vr is not None:
|
||||
solved["vr_device_bridge"] = solver.derive_vr(reference_vr)
|
||||
except Exception as error:
|
||||
solver_error = _describe(error)
|
||||
status, lines = check_status(derived, solved, deriver_error, solver_error)
|
||||
for line in lines:
|
||||
print(line)
|
||||
return status
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the HostAbi deriver and the solver on one update and compare them"
|
||||
)
|
||||
parser.add_argument("--reference-lib", required=True, metavar="REF")
|
||||
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
|
||||
parser.add_argument(
|
||||
"--reference-compatibility", action="append", default=[], metavar="MANIFEST"
|
||||
)
|
||||
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
||||
parser.add_argument(
|
||||
"--cache-dir",
|
||||
default=str(Path.home() / ".cache" / "mocktail" / "function_index"),
|
||||
help="where function indexes are kept; empty to disable",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vr", action="store_true", help="also compare the VR device bridge"
|
||||
)
|
||||
arguments = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
||||
try:
|
||||
return check(arguments)
|
||||
except AnalyzerError as error:
|
||||
print(f"roblox-abi-check: error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Evidence-scoring solver that finds HostAbi addresses in a new libroblox.
|
||||
|
||||
Each function in both builds is indexed by `roblox_function_index.py`. A
|
||||
reference function is matched through independent clues:
|
||||
|
||||
- the same instruction shape, unique in both builds
|
||||
- callers and callees that are already matched
|
||||
- the strings it references
|
||||
- its data references, shifted by the distance mapped for those addresses
|
||||
- its place between matched neighbours
|
||||
|
||||
A match is accepted only with a score of at least ACCEPT_SCORE and a lead of
|
||||
at least ACCEPT_LEAD over the runner-up. `roblox_abi_targets.py` turns the
|
||||
matches into a profile with the same shape as the one
|
||||
`derive_roblox_host_abi_profile.py` writes, `roblox_abi_vr.py` into the VR
|
||||
device bridge, and `roblox_abi_check.py` compares the two on one update.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import collections
|
||||
from dataclasses import dataclass
|
||||
import difflib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
|
||||
def _load_sibling(name: str, file_name: str):
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
path = Path(__file__).resolve().with_name(file_name)
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError(f"cannot load {file_name}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
INDEX = _load_sibling("mocktail_roblox_function_index", "roblox_function_index.py")
|
||||
TARGETS = _load_sibling("mocktail_roblox_abi_targets", "roblox_abi_targets.py")
|
||||
VR = _load_sibling("mocktail_roblox_abi_vr", "roblox_abi_vr.py")
|
||||
DERIVER = INDEX.DERIVER
|
||||
FunctionIndex = INDEX.FunctionIndex
|
||||
Instruction = INDEX.Instruction
|
||||
AnalyzerError = DERIVER.AnalyzerError
|
||||
format_rva = DERIVER.format_rva
|
||||
check_inputs = TARGETS.check_inputs
|
||||
validate_fmod_source = TARGETS.validate_fmod_source
|
||||
|
||||
ACCEPT_SCORE = 0.6
|
||||
ACCEPT_LEAD = 0.2
|
||||
# Candidates that get the full, disassembling score after the cheap one.
|
||||
FULL_SCORE_LIMIT = 64
|
||||
# A data address used by more functions than this is too common to narrow
|
||||
# the candidates by itself.
|
||||
XREF_FANOUT_LIMIT = 64
|
||||
# Scores are float sums; a lead that equals ACCEPT_LEAD on paper may be
|
||||
# a few ulps short.
|
||||
_TOLERANCE = 1e-9
|
||||
|
||||
|
||||
def choose_candidate(
|
||||
scored: Sequence[tuple[float, int]], tie_breaker: int | None = None
|
||||
) -> int | None:
|
||||
"""Returns the leading candidate, or None when no lead is clear.
|
||||
|
||||
`tie_breaker` picks among the candidates that clear ACCEPT_SCORE and sit
|
||||
within ACCEPT_LEAD of the best; it cannot lift or overrule any other.
|
||||
"""
|
||||
if not scored:
|
||||
return None
|
||||
ranked = sorted(scored, key=lambda item: item[0], reverse=True)
|
||||
best_score, best = ranked[0]
|
||||
runner_up = ranked[1][0] if len(ranked) > 1 else 0.0
|
||||
if best_score >= ACCEPT_SCORE and best_score - runner_up >= ACCEPT_LEAD - _TOLERANCE:
|
||||
return best
|
||||
tied = {
|
||||
rva for value, rva in ranked
|
||||
if value >= ACCEPT_SCORE and best_score - value < ACCEPT_LEAD - _TOLERANCE
|
||||
}
|
||||
return tie_breaker if tie_breaker in tied else None
|
||||
|
||||
|
||||
def align_instructions(
|
||||
reference_shapes: Sequence[str], candidate_shapes: Sequence[str]
|
||||
) -> dict[int, int]:
|
||||
"""Maps reference instruction indexes to candidate indexes of equal shape."""
|
||||
matcher = difflib.SequenceMatcher(
|
||||
None, reference_shapes, candidate_shapes, autojunk=False
|
||||
)
|
||||
mapping = {}
|
||||
for block in matcher.get_matching_blocks():
|
||||
for offset in range(block.size):
|
||||
mapping[block.a + offset] = block.b + offset
|
||||
return mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Match:
|
||||
rva: int
|
||||
score: float
|
||||
lead: float
|
||||
# "score" when the score alone decided, "vtable" when a slot broke a tie.
|
||||
decided_by: str
|
||||
|
||||
|
||||
def _share(expected: Sequence[Any], present: set[Any]) -> float | None:
|
||||
if not expected:
|
||||
return None
|
||||
return sum(1 for item in expected if item in present) / len(expected)
|
||||
|
||||
|
||||
def _weighted(parts: Sequence[tuple[float | None, float]]) -> float:
|
||||
present = [(value, weight) for value, weight in parts if value is not None]
|
||||
return sum(value * weight for value, weight in present) / sum(
|
||||
weight for _, weight in present
|
||||
)
|
||||
|
||||
|
||||
class Solver(TARGETS.TargetDerivation, VR.VrTargets):
|
||||
def __init__(self, reference: Any, candidate: Any, cache_dir: Path | None = None):
|
||||
self._start(FunctionIndex(reference, cache_dir), FunctionIndex(candidate, cache_dir))
|
||||
|
||||
@classmethod
|
||||
def from_indexes(cls, reference: Any, candidate: Any) -> "Solver":
|
||||
solver = cls.__new__(cls)
|
||||
solver._start(reference, candidate)
|
||||
return solver
|
||||
|
||||
def _start(self, reference: Any, candidate: Any) -> None:
|
||||
self.reference = reference
|
||||
self.candidate = candidate
|
||||
self.pairs: dict[int, int] = {}
|
||||
self._owners: dict[int, int] = {}
|
||||
for start, other in self._shape_anchors().items():
|
||||
self._pair(start, other)
|
||||
self._propagate_calls()
|
||||
self.data_map = self._map_data_references()
|
||||
self.matches: dict[str, Match] = {}
|
||||
self._claimed: dict[int, int] = {}
|
||||
|
||||
def _pair(self, start: int, other: int) -> None:
|
||||
"""Pairs two functions, dropping any pair that held either one."""
|
||||
owner = self._owners.get(other)
|
||||
if owner is not None and owner != start:
|
||||
del self.pairs[owner]
|
||||
previous = self.pairs.get(start)
|
||||
if previous is not None and previous != other:
|
||||
del self._owners[previous]
|
||||
self.pairs[start] = other
|
||||
self._owners[other] = start
|
||||
|
||||
def _shape_anchors(self) -> dict[int, int]:
|
||||
reference_hashes = collections.defaultdict(list)
|
||||
candidate_hashes = collections.defaultdict(list)
|
||||
for start, info in self.reference.functions.items():
|
||||
reference_hashes[info.shape_hash].append(start)
|
||||
for start, info in self.candidate.functions.items():
|
||||
candidate_hashes[info.shape_hash].append(start)
|
||||
pairs = {}
|
||||
for shape_hash, starts in reference_hashes.items():
|
||||
others = candidate_hashes.get(shape_hash, ())
|
||||
if len(starts) == 1 and len(others) == 1:
|
||||
pairs[starts[0]] = others[0]
|
||||
return pairs
|
||||
|
||||
def _propagate_calls(self) -> None:
|
||||
# Matched functions with as many calls pair their callees in order.
|
||||
reference, candidate = self.reference.functions, self.candidate.functions
|
||||
pending = list(self.pairs.items())
|
||||
while pending:
|
||||
discovered = []
|
||||
for reference_start, candidate_start in pending:
|
||||
reference_calls = reference[reference_start].calls
|
||||
candidate_calls = candidate[candidate_start].calls
|
||||
if len(reference_calls) != len(candidate_calls):
|
||||
continue
|
||||
for callee, other in zip(reference_calls, candidate_calls):
|
||||
if (
|
||||
callee in self.pairs
|
||||
or other in self._owners
|
||||
or callee not in reference
|
||||
or other not in candidate
|
||||
):
|
||||
continue
|
||||
size = reference[callee].size
|
||||
if abs(size - candidate[other].size) <= max(16, size // 4):
|
||||
self._pair(callee, other)
|
||||
discovered.append((callee, other))
|
||||
pending = discovered
|
||||
|
||||
def _map_data_references(self) -> dict[int, int]:
|
||||
votes: dict[int, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||
for reference_start, candidate_start in self.pairs.items():
|
||||
reference_refs = self.reference.functions[reference_start].refs
|
||||
candidate_refs = self.candidate.functions[candidate_start].refs
|
||||
if len(reference_refs) == len(candidate_refs):
|
||||
for reference_target, candidate_target in zip(reference_refs, candidate_refs):
|
||||
votes[reference_target][candidate_target] += 1
|
||||
return {target: counter.most_common(1)[0][0] for target, counter in votes.items()}
|
||||
|
||||
def _gap_candidates(self, start: int) -> list[int]:
|
||||
starts = self.reference.starts
|
||||
index = bisect.bisect_left(starts, start)
|
||||
below = next(
|
||||
(self.pairs[starts[i]] for i in range(index - 1, -1, -1) if starts[i] in self.pairs),
|
||||
self.candidate.text.address,
|
||||
)
|
||||
above = next(
|
||||
(self.pairs[starts[i]] for i in range(index + 1, len(starts)) if starts[i] in self.pairs),
|
||||
self.candidate.text.address + self.candidate.text.size,
|
||||
)
|
||||
if above <= below:
|
||||
return []
|
||||
low = bisect.bisect_left(self.candidate.starts, below)
|
||||
high = bisect.bisect_right(self.candidate.starts, above)
|
||||
return self.candidate.starts[low:high]
|
||||
|
||||
def _neighbour_candidates(self, start: int) -> set[int]:
|
||||
found = set()
|
||||
for callee in self.reference.functions[start].calls:
|
||||
if callee in self.pairs:
|
||||
found.update(self.candidate.callers.get(self.pairs[callee], ()))
|
||||
for caller in self.reference.callers.get(start, ()):
|
||||
if caller in self.pairs:
|
||||
found.update(
|
||||
target
|
||||
for target in self.candidate.functions[self.pairs[caller]].calls
|
||||
if target in self.candidate.functions
|
||||
)
|
||||
return found
|
||||
|
||||
def _pattern_candidates(self, start: int) -> set[int]:
|
||||
# Data a function touches usually moves as one block, so its
|
||||
# references shifted by a mapped distance find it in the candidate.
|
||||
refs = set(self.reference.functions[start].refs)
|
||||
distances = collections.Counter(
|
||||
self.data_map[target] - target for target in refs if target in self.data_map
|
||||
)
|
||||
# A common address adds a vote but cannot bring in a candidate alone.
|
||||
votes = collections.Counter()
|
||||
found_by_rare = set()
|
||||
for target in refs:
|
||||
users = set()
|
||||
for distance, _ in distances.most_common(3):
|
||||
found = self.candidate.data_users.get(target + distance, ())
|
||||
users.update(found)
|
||||
if len(found) <= XREF_FANOUT_LIMIT:
|
||||
found_by_rare.update(found)
|
||||
votes.update(users)
|
||||
needed = max(1, len(refs) // 2)
|
||||
return {user for user in found_by_rare if votes[user] >= needed}
|
||||
|
||||
def _graph_score(self, start: int, other: int) -> float:
|
||||
reference = self.reference.functions[start]
|
||||
candidate = self.candidate.functions[other]
|
||||
size = min(reference.size, candidate.size) / max(reference.size, candidate.size)
|
||||
calls = [self.pairs[c] for c in reference.calls if c in self.pairs]
|
||||
callers = [self.pairs[c] for c in self.reference.callers.get(start, ()) if c in self.pairs]
|
||||
data = [self.data_map[t] for t in reference.refs if t in self.data_map]
|
||||
return _weighted(
|
||||
(
|
||||
(size, 1.0),
|
||||
(_share(calls, set(candidate.calls)), 2.0),
|
||||
(_share(callers, set(self.candidate.callers.get(other, ()))), 2.0),
|
||||
(_share(data, set(candidate.refs)), 2.0),
|
||||
)
|
||||
)
|
||||
|
||||
def _full_score(self, start: int, other: int, graph: float) -> float:
|
||||
similarity = difflib.SequenceMatcher(
|
||||
None, self.reference.shapes(start), self.candidate.shapes(other), autojunk=False
|
||||
).ratio()
|
||||
strings = [
|
||||
text
|
||||
for text in map(self.reference.string_at, self.reference.functions[start].refs)
|
||||
if text is not None
|
||||
]
|
||||
candidate_strings = set(
|
||||
map(self.candidate.string_at, self.candidate.functions[other].refs)
|
||||
)
|
||||
return _weighted(
|
||||
(
|
||||
(similarity, 3.0),
|
||||
(_share(strings, candidate_strings), 2.0),
|
||||
(graph, 5.0),
|
||||
)
|
||||
)
|
||||
|
||||
def match_function(
|
||||
self,
|
||||
name: str,
|
||||
start: int,
|
||||
contract: Callable[[Any, int], bool] | None = None,
|
||||
vtable_slot: int | None = None,
|
||||
) -> int:
|
||||
"""Matches one reference function.
|
||||
|
||||
A contract drops candidates outright. `vtable_slot` is the candidate
|
||||
that sits in the matching slot of an already located vtable; it only
|
||||
breaks a tie between candidates that pass on their own evidence.
|
||||
"""
|
||||
if start not in self.reference.functions:
|
||||
raise AnalyzerError(f"reference {name} is not a function start")
|
||||
if name in self.matches:
|
||||
return self.matches[name].rva
|
||||
candidates = set(self._gap_candidates(start))
|
||||
candidates |= self._neighbour_candidates(start)
|
||||
candidates |= self._pattern_candidates(start)
|
||||
if start in self.pairs:
|
||||
candidates.add(self.pairs[start])
|
||||
if vtable_slot in self.candidate.functions:
|
||||
candidates.add(vtable_slot)
|
||||
size = self.reference.functions[start].size
|
||||
candidates = {
|
||||
other
|
||||
for other in candidates
|
||||
if self._claimed.get(other, start) == start
|
||||
and self.candidate.functions[other].size <= 8 * size + 64
|
||||
and (contract is None or contract(self.candidate.image, other))
|
||||
}
|
||||
graph = {other: self._graph_score(start, other) for other in candidates}
|
||||
shortlist = sorted(candidates, key=graph.__getitem__, reverse=True)[:FULL_SCORE_LIMIT]
|
||||
if vtable_slot in candidates and vtable_slot not in shortlist:
|
||||
shortlist.append(vtable_slot)
|
||||
scored = [(self._full_score(start, other, graph[other]), other) for other in shortlist]
|
||||
chosen = choose_candidate(scored)
|
||||
decided_by = "score"
|
||||
if chosen is None and vtable_slot is not None:
|
||||
chosen = choose_candidate(scored, tie_breaker=vtable_slot)
|
||||
decided_by = "vtable"
|
||||
if chosen is None:
|
||||
ranked = sorted(scored, reverse=True)[:2]
|
||||
raise AnalyzerError(
|
||||
f"{name} has no clear candidate: "
|
||||
+ ", ".join(f"{format_rva(rva)}={value:.2f}" for value, rva in ranked)
|
||||
)
|
||||
chosen_score = next(value for value, other in scored if other == chosen)
|
||||
runner_up = max((value for value, other in scored if other != chosen), default=0.0)
|
||||
self.matches[name] = Match(chosen, chosen_score, chosen_score - runner_up, decided_by)
|
||||
self._claimed[chosen] = start
|
||||
self._pair(start, chosen)
|
||||
return chosen
|
||||
|
||||
def _aligned(self, name: str, start: int) -> tuple[list[Instruction], list[Instruction], dict[int, int]]:
|
||||
other = self.match_function(name, start)
|
||||
reference = self.reference.instructions(start)
|
||||
candidate = self.candidate.instructions(other)
|
||||
mapping = align_instructions(
|
||||
[item.shape for item in reference], [item.shape for item in candidate]
|
||||
)
|
||||
return reference, candidate, mapping
|
||||
|
||||
def map_instruction(self, name: str, rva: int) -> int:
|
||||
start = self.reference.containing(rva)
|
||||
if start is None:
|
||||
raise AnalyzerError(f"reference {name} is outside every function")
|
||||
reference, candidate, mapping = self._aligned(f"{name} function", start)
|
||||
index = next((i for i, item in enumerate(reference) if item.address == rva), None)
|
||||
if index is None or index not in mapping:
|
||||
raise AnalyzerError(f"{name} instruction has no counterpart")
|
||||
return candidate[mapping[index]].address
|
||||
|
||||
def map_data(self, name: str, function_name: str, start: int, target: int) -> int:
|
||||
reference, candidate, mapping = self._aligned(function_name, start)
|
||||
found = set()
|
||||
for index, item in enumerate(reference):
|
||||
if target not in item.rip_targets:
|
||||
continue
|
||||
if index not in mapping:
|
||||
raise AnalyzerError(f"{name} reference instruction has no counterpart")
|
||||
other = candidate[mapping[index]]
|
||||
if len(other.rip_targets) != len(item.rip_targets):
|
||||
raise AnalyzerError(f"{name} RIP-relative operand count changed")
|
||||
found.update(
|
||||
other.rip_targets[position]
|
||||
for position, value in enumerate(item.rip_targets)
|
||||
if value == target
|
||||
)
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(f"{name} has no unique mapped data address")
|
||||
return found.pop()
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""HostAbi targets that `roblox_abi_solver.py` derives from its matches.
|
||||
|
||||
`TargetDerivation` turns matched functions into the HostAbi profile, the
|
||||
derivation anchors and the runtime compatibility block, in the shape that
|
||||
`derive_roblox_host_abi_profile.py` writes. It reuses the deriver's input
|
||||
checks, contracts and post-processing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def _load_index():
|
||||
name = "mocktail_roblox_function_index"
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
path = Path(__file__).resolve().with_name("roblox_function_index.py")
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("cannot load roblox_function_index.py")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DERIVER = _load_index().DERIVER
|
||||
AnalyzerError = DERIVER.AnalyzerError
|
||||
format_rva = DERIVER.format_rva
|
||||
FMOD_OUTPUT_FIELDS = DERIVER.FMOD_OUTPUT_FIELDS
|
||||
FMOD_INPUT_FIELDS = DERIVER.FMOD_INPUT_FIELDS
|
||||
fmod_slots = DERIVER.fmod_vtable_slots
|
||||
|
||||
|
||||
def check_inputs(reference: Any, candidate: Any, reference_sidecar: dict[str, Any]) -> None:
|
||||
"""Applies the deriver's identity and preflight checks to both builds."""
|
||||
DERIVER.check_derivation_inputs(reference, candidate, reference_sidecar)
|
||||
|
||||
|
||||
def validate_fmod_source(source: Any) -> int:
|
||||
"""Checks a reference FMOD bridge the way the deriver does; returns its layout."""
|
||||
if (
|
||||
not isinstance(source, dict)
|
||||
or not FMOD_OUTPUT_FIELDS <= source.keys()
|
||||
or source.keys() - FMOD_OUTPUT_FIELDS - set(FMOD_INPUT_FIELDS) - {"vtable_layout_version"}
|
||||
):
|
||||
raise AnalyzerError("reference FMOD output-device profile is incomplete")
|
||||
present = [field for field in FMOD_INPUT_FIELDS if field in source]
|
||||
if present and len(present) != len(FMOD_INPUT_FIELDS):
|
||||
raise AnalyzerError("reference FMOD input profile is incomplete")
|
||||
layout = source.get("vtable_layout_version", 1)
|
||||
if type(layout) is not int or layout not in (1, 2):
|
||||
raise AnalyzerError("reference FMOD vtable layout is unsupported")
|
||||
return layout
|
||||
|
||||
|
||||
class TargetDerivation:
|
||||
"""Mixin for `Solver`: needs `reference`, `candidate`, `matches`,
|
||||
`match_function`, `map_data` and `map_instruction`."""
|
||||
|
||||
def _check_registry_allocator(
|
||||
self,
|
||||
reference_registry: int,
|
||||
candidate_registry: int,
|
||||
reference_allocate: int,
|
||||
candidate_allocate: int,
|
||||
) -> None:
|
||||
# The registry initializer's tenth instruction calls the allocator bridge.
|
||||
for index, start, allocate, side in (
|
||||
(self.reference, reference_registry, reference_allocate, "reference"),
|
||||
(self.candidate, candidate_registry, candidate_allocate, "candidate"),
|
||||
):
|
||||
code = index.instructions(start)
|
||||
if len(code) <= 9 or code[9].shape != "call" or code[9].branch_target != allocate:
|
||||
raise AnalyzerError(
|
||||
f"{side} registry initializer does not call its allocator bridge"
|
||||
)
|
||||
|
||||
def derive_profile(
|
||||
self, reference_sidecar: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
reference_profile = reference_sidecar["profile"]
|
||||
anchors = reference_sidecar["derivation_anchors"]
|
||||
DERIVER.validate_profile_shape(reference_profile, anchors)
|
||||
reference_image = self.reference.image
|
||||
candidate_image = self.candidate.image
|
||||
check_inputs(reference_image, candidate_image, reference_sidecar)
|
||||
parse = DERIVER.parse_rva
|
||||
bridges = []
|
||||
found = {}
|
||||
for entry in reference_profile["bridge_entries"]:
|
||||
rva = self.match_function(entry["label"], parse(entry["rva"], entry["label"]))
|
||||
found[entry["label"]] = rva
|
||||
bridges.append({"rva": format_rva(rva), "kind": entry["kind"], "label": entry["label"]})
|
||||
|
||||
seeds = reference_profile["data_seeds"]
|
||||
arena_initializer = parse(seeds["arena_initializer"], "arena initializer")
|
||||
thread_initializer = parse(seeds["allocator_thread_initializer"], "thread initializer")
|
||||
self.match_function("arena-initializer", arena_initializer)
|
||||
self.match_function("allocator-thread-initializer", thread_initializer)
|
||||
|
||||
bootstrap = reference_profile["native_pre_jni_bootstrap"]
|
||||
DERIVER.validate_reference_registry_slot(reference_image, bootstrap)
|
||||
registry = self.match_function(
|
||||
"registry-initializer", parse(bootstrap["registry_initializer"], "registry")
|
||||
)
|
||||
reference_allocate = parse(
|
||||
DERIVER.bridge_map(reference_profile)["allocate"]["rva"], "reference allocate"
|
||||
)
|
||||
self._check_registry_allocator(
|
||||
parse(bootstrap["registry_initializer"], "registry"),
|
||||
registry, reference_allocate, found["allocate"],
|
||||
)
|
||||
registry_slot = DERIVER.derive_registry_slot(candidate_image, registry)
|
||||
|
||||
reference_init = reference_image.init_array_relocations()
|
||||
candidate_init = candidate_image.init_array_relocations()
|
||||
begin = DERIVER.LEADING_CONSTRUCTOR_BEGIN
|
||||
count = DERIVER.LEADING_CONSTRUCTOR_COUNT
|
||||
leading = candidate_init[begin : begin + count]
|
||||
offsets = []
|
||||
for index in range(begin, begin + count):
|
||||
expected = parse(anchors["constructor_rvas"][str(index)], f"constructor {index}")
|
||||
if reference_init[index] != expected:
|
||||
raise AnalyzerError(f"reference constructor {index} differs from its anchor")
|
||||
rva = self.match_function(f"constructor-{index}", expected)
|
||||
if leading.count(rva) != 1:
|
||||
raise AnalyzerError(f"constructor {index} left the leading constructors")
|
||||
offsets.append(leading.index(rva))
|
||||
if sorted(offsets) != list(range(count)):
|
||||
raise AnalyzerError("leading constructors do not map one to one")
|
||||
|
||||
singleton = parse(anchors["jni_singleton_initializer_rva"], "JNI singleton")
|
||||
jni_singleton = self.match_function("jni-singleton-initializer", singleton)
|
||||
allocator_anchor = parse(anchors["allocator_object_initializer_rva"], "allocator anchor")
|
||||
empty_anchor = parse(anchors["empty_string_initializer_rva"], "empty string anchor")
|
||||
allocator_function = self.reference.containing(allocator_anchor)
|
||||
empty_function = self.reference.containing(empty_anchor)
|
||||
if allocator_function is None or empty_function is None:
|
||||
raise AnalyzerError("reference anchor is outside every function")
|
||||
|
||||
slots = {
|
||||
"allocator_object_slot": self.map_data(
|
||||
"allocator object slot", "allocator-object-initializer function",
|
||||
allocator_function, parse(seeds["allocator_object_slot"], "allocator slot"),
|
||||
),
|
||||
"empty_string_slot": self.map_data(
|
||||
"empty string slot", "empty-string-initializer function",
|
||||
empty_function, parse(seeds["empty_string_slot"], "empty string slot"),
|
||||
),
|
||||
"jni_singleton_slot": self.map_data(
|
||||
"JNI singleton slot", "jni-singleton-initializer",
|
||||
singleton, parse(seeds["jni_singleton_slot"], "JNI singleton slot"),
|
||||
),
|
||||
"arena_guard_slot": self.map_data(
|
||||
"arena guard slot", "arena-initializer",
|
||||
arena_initializer, parse(seeds["arena_guard_slot"], "arena guard slot"),
|
||||
),
|
||||
"arena_table_slot": self.map_data(
|
||||
"arena table slot", "usable-size",
|
||||
parse(DERIVER.bridge_map(reference_profile)["usable-size"]["rva"], "usable size"),
|
||||
parse(seeds["arena_table_slot"], "arena table slot"),
|
||||
),
|
||||
}
|
||||
for slot in slots.values():
|
||||
candidate_image.require_writable_rva(slot, 8)
|
||||
|
||||
candidate_count = len(candidate_init)
|
||||
reference_count = reference_profile["init_array_count"]
|
||||
boundary = reference_profile["native_mimalloc_thread_initializer_after_constructor"]
|
||||
if not begin <= boundary < begin + count:
|
||||
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||
thread_boundary = begin + offsets[boundary - begin]
|
||||
native_ranges = DERIVER.remapped_constructor_ranges(
|
||||
reference_profile["native_mimalloc_constructor_run_ranges"],
|
||||
offsets, reference_count, candidate_count,
|
||||
)
|
||||
if not any(item["begin"] <= thread_boundary < item["end_exclusive"] for item in native_ranges):
|
||||
raise AnalyzerError("native allocator TLS boundary is no longer verified")
|
||||
profile = {
|
||||
"elf_build_id": candidate_image.build_id,
|
||||
"bridge_entries": bridges,
|
||||
"data_seeds": {
|
||||
"allocator_object_slot": format_rva(slots["allocator_object_slot"]),
|
||||
"empty_string_slot": format_rva(slots["empty_string_slot"]),
|
||||
"jni_singleton_slot": format_rva(slots["jni_singleton_slot"]),
|
||||
"jni_singleton_bytes": seeds["jni_singleton_bytes"],
|
||||
"arena_initializer": format_rva(self.matches["arena-initializer"].rva),
|
||||
"allocator_thread_initializer": format_rva(
|
||||
self.matches["allocator-thread-initializer"].rva
|
||||
),
|
||||
"arena_guard_slot": format_rva(slots["arena_guard_slot"]),
|
||||
"arena_table_slot": format_rva(slots["arena_table_slot"]),
|
||||
"arena_table_slot_count": seeds["arena_table_slot_count"],
|
||||
},
|
||||
"native_allocator": {
|
||||
"allocate": format_rva(found["allocate"]),
|
||||
"deallocate": format_rva(found["free"]),
|
||||
},
|
||||
"init_array_offset": format_rva(candidate_image.sections[".init_array"].address),
|
||||
"init_array_count": candidate_count,
|
||||
"constructor_run_ranges": DERIVER.remapped_constructor_ranges(
|
||||
reference_profile["constructor_run_ranges"],
|
||||
offsets, reference_count, candidate_count,
|
||||
),
|
||||
"native_mimalloc_constructor_run_ranges": native_ranges,
|
||||
"native_mimalloc_thread_initializer_after_constructor": thread_boundary,
|
||||
"native_pre_jni_bootstrap": {
|
||||
"registry_initializer": format_rva(registry),
|
||||
"registry_slot": format_rva(registry_slot),
|
||||
},
|
||||
"default_allocator_strategy": "native_mimalloc",
|
||||
}
|
||||
derived_anchors = {
|
||||
"signature_version": 1,
|
||||
"allocator_object_initializer_rva": format_rva(
|
||||
self.map_instruction("allocator-object-initializer", allocator_anchor)
|
||||
),
|
||||
"empty_string_initializer_rva": format_rva(
|
||||
self.map_instruction("empty-string-initializer", empty_anchor)
|
||||
),
|
||||
"jni_singleton_initializer_rva": format_rva(jni_singleton),
|
||||
"constructor_rvas": {
|
||||
str(index): format_rva(candidate_init[index])
|
||||
for index in range(begin, begin + count)
|
||||
},
|
||||
}
|
||||
DERIVER.validate_profile_shape(profile, derived_anchors)
|
||||
return profile, derived_anchors
|
||||
|
||||
def derive_runtime_compatibility(self, source: dict[str, Any]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
fullscreen = source.get("user_game_settings_fullscreen_setter_rva")
|
||||
if fullscreen is not None:
|
||||
reference_setter = DERIVER.parse_rva(fullscreen, "fullscreen setter")
|
||||
if not DERIVER.has_fullscreen_setter_contract(self.reference.image, reference_setter):
|
||||
raise AnalyzerError("reference fullscreen setter contract changed")
|
||||
setter = self.match_function(
|
||||
"fullscreen-setter", reference_setter, DERIVER.has_fullscreen_setter_contract
|
||||
)
|
||||
result["user_game_settings_fullscreen_setter_rva"] = format_rva(setter)
|
||||
fmod = source.get("fmod_output_device_bridge")
|
||||
if fmod is not None:
|
||||
result["fmod_output_device_bridge"] = self._derive_fmod_bridge(fmod)
|
||||
return result
|
||||
|
||||
def _derive_fmod_bridge(self, source: dict[str, Any]) -> dict[str, Any]:
|
||||
layout = validate_fmod_source(source)
|
||||
has_input = FMOD_INPUT_FIELDS[0] in source
|
||||
slots = fmod_slots(layout, has_input)
|
||||
parse = DERIVER.parse_rva
|
||||
vtable = parse(source["vtable_rva"], "FMOD vtable")
|
||||
reference_relocations = DERIVER.relative_relocation_map(self.reference.image)
|
||||
methods = {}
|
||||
unresolved = []
|
||||
for field, slot in slots.items():
|
||||
method = parse(source[field], field)
|
||||
if reference_relocations.get(vtable + slot * 8) != method:
|
||||
raise AnalyzerError("reference FMOD vtable does not match its methods")
|
||||
try:
|
||||
methods[field] = self.match_function(
|
||||
f"fmod-{field}", method, self._fmod_contract(layout, field)
|
||||
)
|
||||
except AnalyzerError:
|
||||
unresolved.append(field)
|
||||
string_constructor = parse(source["string_constructor_rva"], "FMOD string constructor")
|
||||
if not DERIVER.has_fmod_string_constructor_contract(self.reference.image, string_constructor):
|
||||
raise AnalyzerError("reference FMOD string constructor contract changed")
|
||||
candidate_string_constructor = self.match_function(
|
||||
"fmod-string-constructor",
|
||||
string_constructor,
|
||||
DERIVER.has_fmod_string_constructor_contract,
|
||||
)
|
||||
|
||||
relocations = DERIVER.relative_relocation_map(self.candidate.image)
|
||||
located = self._locate_fmod_vtables(relocations, methods)
|
||||
vtables = sorted({vtable for vtable, _ in located})
|
||||
if len(vtables) != 1:
|
||||
raise AnalyzerError(f"FMOD output vtable matched {len(vtables)} candidate locations")
|
||||
# The located vtable breaks ties for the methods that did not resolve.
|
||||
# Count and info share slots 5 and 6 in both layouts, so the vtable can
|
||||
# fit both; exactly one layout must then hold every method.
|
||||
solutions = []
|
||||
for located_vtable, target_layout in located:
|
||||
target_slots = fmod_slots(target_layout, has_input)
|
||||
resolved = dict(methods)
|
||||
try:
|
||||
for field in unresolved:
|
||||
resolved[field] = self.match_function(
|
||||
f"fmod-{field}",
|
||||
parse(source[field], field),
|
||||
self._fmod_contract(target_layout, field),
|
||||
relocations.get(located_vtable + target_slots[field] * 8),
|
||||
)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if self._locate_fmod_vtables(relocations, resolved) != [
|
||||
(located_vtable, target_layout)
|
||||
]:
|
||||
continue
|
||||
if target_layout == 2 and not DERIVER.has_fmod_device_lists_select_contract(
|
||||
self.candidate.image, resolved["select_method_rva"]
|
||||
):
|
||||
continue
|
||||
solutions.append((located_vtable, target_layout, target_slots, resolved))
|
||||
if len(solutions) != 1:
|
||||
raise AnalyzerError(f"FMOD output vtable holds every method in {len(solutions)} layouts")
|
||||
located_vtable, target_layout, target_slots, methods = solutions[0]
|
||||
self.candidate.image.require_relro_rva(
|
||||
located_vtable, (max(target_slots.values()) + 1) * 8
|
||||
)
|
||||
result = {"vtable_rva": format_rva(located_vtable)}
|
||||
result["string_constructor_rva"] = format_rva(candidate_string_constructor)
|
||||
result.update((field, format_rva(rva)) for field, rva in methods.items())
|
||||
if target_layout != 1:
|
||||
result["vtable_layout_version"] = target_layout
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fmod_contract(layout: int, field: str) -> Callable[[Any, int], bool] | None:
|
||||
if layout == 2 and field == "select_method_rva":
|
||||
return DERIVER.has_fmod_device_lists_select_contract
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _locate_fmod_vtables(
|
||||
relocations: dict[int, int], methods: dict[str, int]
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Returns (vtable, layout) for each vtable holding every method."""
|
||||
located = []
|
||||
for layout in (1, 2):
|
||||
slots = fmod_slots(layout, True)
|
||||
held = {slots[field]: rva for field, rva in methods.items()}
|
||||
located.extend(
|
||||
(vtable, layout)
|
||||
for vtable in TargetDerivation._vtables_holding(relocations, held)
|
||||
)
|
||||
return sorted(located)
|
||||
|
||||
@staticmethod
|
||||
def _vtables_holding(relocations: dict[int, int], methods: dict[int, int]) -> list[int]:
|
||||
"""Returns every vtable whose slots hold all of `methods`."""
|
||||
if not methods:
|
||||
raise AnalyzerError("no vtable method was matched")
|
||||
slot, method = next(iter(methods.items()))
|
||||
return sorted(
|
||||
offset - slot * 8
|
||||
for offset, value in relocations.items()
|
||||
if value == method
|
||||
and offset >= slot * 8
|
||||
and (offset - slot * 8) % 8 == 0
|
||||
and all(
|
||||
relocations.get(offset - slot * 8 + index * 8) == rva
|
||||
for index, rva in methods.items()
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""VR device bridge targets that `roblox_abi_solver.py` derives.
|
||||
|
||||
`VrTargets.derive_vr` takes one build's VR bridge (the `VrBuildContract`
|
||||
fields in `src/vr/roblox_vr_device_bridge.cc`, as hex strings) and returns
|
||||
the same fields for the candidate build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_index():
|
||||
name = "mocktail_roblox_function_index"
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
path = Path(__file__).resolve().with_name("roblox_function_index.py")
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("cannot load roblox_function_index.py")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DERIVER = _load_index().DERIVER
|
||||
AnalyzerError = DERIVER.AnalyzerError
|
||||
format_rva = DERIVER.format_rva
|
||||
|
||||
VR_FIELDS = (
|
||||
"vtable_rva",
|
||||
"constructor_rva",
|
||||
"state_getter_rva",
|
||||
"eye_getter_rva",
|
||||
"eye_initializer_rva",
|
||||
"emulator_flag_storage_rva",
|
||||
"device_create_framebuffer_vtable_offset",
|
||||
"state_memcpy_rva",
|
||||
"pointer_frame_rva",
|
||||
"workspace_getter_rva",
|
||||
"camera_vtable_offset",
|
||||
"hand_pitch_storage_rva",
|
||||
"haptic_sink_rva",
|
||||
"pointer_hand_selector_offset",
|
||||
"camera_head_scale_offset",
|
||||
"keep_haptics_flag_rva",
|
||||
"vr_haptics_flag_rva",
|
||||
"support_generic_haptics_flag_rva",
|
||||
"fix_register_motors_flag_rva",
|
||||
)
|
||||
# Fast flag and setting storage, found through the functions that use it.
|
||||
_STORAGE_FIELDS = (
|
||||
"emulator_flag_storage_rva",
|
||||
"hand_pitch_storage_rva",
|
||||
"keep_haptics_flag_rva",
|
||||
"vr_haptics_flag_rva",
|
||||
"support_generic_haptics_flag_rva",
|
||||
"fix_register_motors_flag_rva",
|
||||
)
|
||||
# Storage users tried, smallest first; registration functions are small.
|
||||
_STORAGE_USER_LIMIT = 8
|
||||
_VTABLE_SLOT_LIMIT = 0x400
|
||||
# The constructor stores the vtable address plus the Itanium header.
|
||||
_VTABLE_REFERENCE_SPAN = 0x40
|
||||
_NUMBER = r"(0x[0-9a-f]+|\d+)"
|
||||
_SLOT_LOAD = re.compile(r"^mov (\w+), qword ptr \[\1 \+ ")
|
||||
# How far back the load of the vtable pointer itself may sit.
|
||||
_VTABLE_LOAD_WINDOW = 6
|
||||
|
||||
|
||||
def _vtable_call(instructions: list[Any], index: int) -> bool:
|
||||
"""A call through a vtable slot, or a load of the slot from a vtable
|
||||
pointer loaded just before into the same register."""
|
||||
shape = instructions[index].shape
|
||||
if shape.startswith("call qword ptr ["):
|
||||
return True
|
||||
load = _SLOT_LOAD.match(shape)
|
||||
if load is None:
|
||||
return False
|
||||
vtable_load = re.compile(rf"^mov {load.group(1)}, qword ptr \[\w+\]$")
|
||||
return any(
|
||||
vtable_load.match(previous.shape)
|
||||
for previous in instructions[max(0, index - _VTABLE_LOAD_WINDOW) : index]
|
||||
)
|
||||
|
||||
|
||||
class VrTargets:
|
||||
"""Mixin for `Solver`: needs `reference`, `candidate`, `matches`,
|
||||
`match_function`, `map_data` and `_aligned`."""
|
||||
|
||||
def map_operand(
|
||||
self, name: str, function_name: str, start: int, value: int, vtable_call: bool = False
|
||||
) -> int:
|
||||
"""Maps a field offset that a matched function uses as a displacement.
|
||||
|
||||
Stack-relative uses are ignored; with `vtable_call`, so is every use
|
||||
that is not a virtual call. An instruction whose shape changed is
|
||||
looked for between its nearest aligned neighbours.
|
||||
"""
|
||||
reference, candidate, mapping = self._aligned(function_name, start)
|
||||
token = re.compile(rf"(?<![0-9a-fx]){re.escape(hex(value))}(?![0-9a-f])")
|
||||
found = set()
|
||||
for index, item in enumerate(reference):
|
||||
if not token.search(item.shape) or "rsp" in item.shape or "rbp" in item.shape:
|
||||
continue
|
||||
if vtable_call and not _vtable_call(reference, index):
|
||||
continue
|
||||
pattern = re.compile(
|
||||
"^" + token.sub(lambda _: _NUMBER, re.escape(item.shape)) + "$"
|
||||
)
|
||||
if index in mapping:
|
||||
window = range(mapping[index], mapping[index] + 1)
|
||||
else:
|
||||
below = max((k for k in mapping if k < index), default=None)
|
||||
above = min((k for k in mapping if k > index), default=None)
|
||||
low = 0 if below is None else mapping[below] + 1
|
||||
high = len(candidate) if above is None else mapping[above]
|
||||
window = range(low, high)
|
||||
values = []
|
||||
for position in window:
|
||||
if vtable_call and not _vtable_call(candidate, position):
|
||||
continue
|
||||
matched = pattern.match(candidate[position].shape)
|
||||
if matched is not None and len({*matched.groups()}) == 1:
|
||||
values.append(int(matched.group(1), 0))
|
||||
if len(values) != 1:
|
||||
raise AnalyzerError(f"{name} has {len(values)} candidate offsets")
|
||||
found.add(values[0])
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(f"{name} maps to {len(found)} offsets")
|
||||
return found.pop()
|
||||
|
||||
def map_call_target(self, name: str, function_name: str, start: int, target: int) -> int:
|
||||
"""Maps the target of a direct call inside a matched function."""
|
||||
reference, candidate, mapping = self._aligned(function_name, start)
|
||||
found = set()
|
||||
for index, item in enumerate(reference):
|
||||
if item.shape != "call" or item.branch_target != target:
|
||||
continue
|
||||
if index not in mapping:
|
||||
raise AnalyzerError(f"{name} call has no counterpart")
|
||||
found.add(candidate[mapping[index]].branch_target)
|
||||
if len(found) != 1 or None in found:
|
||||
raise AnalyzerError(f"{name} has no unique call target")
|
||||
return found.pop()
|
||||
|
||||
def _map_storage(self, name: str, target: int) -> int:
|
||||
users = sorted(
|
||||
self.reference.data_users.get(target, ()),
|
||||
key=lambda start: self.reference.functions[start].size,
|
||||
)[:_STORAGE_USER_LIMIT]
|
||||
found = set()
|
||||
for user in users:
|
||||
user_name = f"{name} user {format_rva(user)}"
|
||||
try:
|
||||
self.match_function(user_name, user)
|
||||
found.add(self.map_data(name, user_name, user, target))
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(f"{name} maps to {len(found)} addresses")
|
||||
return found.pop()
|
||||
|
||||
def _map_vtable(self, constructor: int, vtable: int) -> int:
|
||||
found = set()
|
||||
for item in self.reference.instructions(constructor):
|
||||
for target in item.rip_targets:
|
||||
if 0 <= target - vtable < _VTABLE_REFERENCE_SPAN:
|
||||
mapped = self.map_data("VR vtable", "vr-constructor", constructor, target)
|
||||
found.add(mapped - (target - vtable))
|
||||
if len(found) != 1:
|
||||
raise AnalyzerError(f"VR vtable maps to {len(found)} addresses")
|
||||
return found.pop()
|
||||
|
||||
@staticmethod
|
||||
def _slot(relocations: dict[int, int], vtable: int, method: int) -> int:
|
||||
slots = [
|
||||
offset
|
||||
for offset in range(0, _VTABLE_SLOT_LIMIT, 8)
|
||||
if relocations.get(vtable + offset) == method
|
||||
]
|
||||
if not slots:
|
||||
raise AnalyzerError(f"reference VR vtable does not hold {format_rva(method)}")
|
||||
return slots[0]
|
||||
|
||||
def derive_vr(self, source: dict[str, str]) -> dict[str, str]:
|
||||
missing = [field for field in VR_FIELDS if field not in source]
|
||||
if missing:
|
||||
raise AnalyzerError("reference VR bridge lacks " + ", ".join(missing))
|
||||
value = {field: DERIVER.parse_rva(source[field], field) for field in VR_FIELDS}
|
||||
result = {}
|
||||
constructor = self.match_function("vr-constructor", value["constructor_rva"])
|
||||
result["constructor_rva"] = constructor
|
||||
vtable = self._map_vtable(value["constructor_rva"], value["vtable_rva"])
|
||||
result["vtable_rva"] = vtable
|
||||
reference_relocations = DERIVER.relative_relocation_map(self.reference.image)
|
||||
relocations = DERIVER.relative_relocation_map(self.candidate.image)
|
||||
slots = {
|
||||
field: self._slot(reference_relocations, value["vtable_rva"], value[field])
|
||||
for field in (
|
||||
"state_getter_rva", "eye_getter_rva", "eye_initializer_rva", "haptic_sink_rva"
|
||||
)
|
||||
}
|
||||
self.candidate.image.require_relro_rva(vtable, max(slots.values()) + 8)
|
||||
for field in ("state_getter_rva", "eye_getter_rva", "eye_initializer_rva"):
|
||||
result[field] = self.match_function(
|
||||
f"vr-{field}", value[field], vtable_slot=relocations.get(vtable + slots[field])
|
||||
)
|
||||
# The haptic sink is a one-byte ret stub, so only its slot identifies it.
|
||||
sink = relocations.get(vtable + slots["haptic_sink_rva"])
|
||||
if sink is None or sink not in self.candidate.functions or (
|
||||
self.candidate.functions[sink].size != 1
|
||||
):
|
||||
raise AnalyzerError("VR haptic sink slot does not hold a ret stub")
|
||||
result["haptic_sink_rva"] = sink
|
||||
result["state_memcpy_rva"] = self.map_call_target(
|
||||
"VR state memcpy", "vr-state_getter_rva",
|
||||
value["state_getter_rva"], value["state_memcpy_rva"],
|
||||
)
|
||||
result["device_create_framebuffer_vtable_offset"] = self.map_operand(
|
||||
"VR framebuffer slot", "vr-eye_initializer_rva",
|
||||
value["eye_initializer_rva"], value["device_create_framebuffer_vtable_offset"],
|
||||
vtable_call=True,
|
||||
)
|
||||
result["pointer_frame_rva"] = self.match_function(
|
||||
"vr-pointer-frame", value["pointer_frame_rva"]
|
||||
)
|
||||
result["workspace_getter_rva"] = self.map_call_target(
|
||||
"VR workspace getter", "vr-pointer-frame",
|
||||
value["pointer_frame_rva"], value["workspace_getter_rva"],
|
||||
)
|
||||
for field in (
|
||||
"camera_vtable_offset", "pointer_hand_selector_offset", "camera_head_scale_offset"
|
||||
):
|
||||
result[field] = self.map_operand(
|
||||
field, "vr-pointer-frame", value["pointer_frame_rva"], value[field],
|
||||
vtable_call=field == "camera_vtable_offset",
|
||||
)
|
||||
for field in _STORAGE_FIELDS:
|
||||
result[field] = self._map_storage(field, value[field])
|
||||
self.candidate.image.require_writable_rva(result[field], 1)
|
||||
return {field: format_rva(result[field]) for field in VR_FIELDS}
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Function index of one libroblox image for `roblox_abi_solver.py`.
|
||||
|
||||
Every function listed in `.eh_frame_hdr` is decoded once and reduced to a
|
||||
normalized instruction shape, its direct calls and its RIP-relative data
|
||||
references. Shapes drop branch targets, RIP displacements and immediates of
|
||||
0x1000 and above, so a function moved by the linker keeps its shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import collections
|
||||
from dataclasses import dataclass
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_deriver():
|
||||
name = "mocktail_host_abi_deriver_for_solver"
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
path = Path(__file__).resolve().with_name("derive_roblox_host_abi_profile.py")
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("cannot load the HostAbi deriver")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DERIVER = _load_deriver()
|
||||
AnalyzerError = DERIVER.AnalyzerError
|
||||
|
||||
_RIP_OPERAND = re.compile(r"\[rip ([+-]) (0x[0-9a-f]+|\d+)\]")
|
||||
_IMMEDIATE = re.compile(r"0x[0-9a-f]+|\b\d+\b")
|
||||
# Smaller immediates are offsets and flags that stay put between builds.
|
||||
# Larger ones are addresses and sizes that move.
|
||||
_STABLE_IMMEDIATE_LIMIT = 0x1000
|
||||
|
||||
|
||||
def _stable_immediate(match: re.Match) -> str:
|
||||
return "I" if int(match.group(0), 0) >= _STABLE_IMMEDIATE_LIMIT else match.group(0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Instruction:
|
||||
address: int
|
||||
shape: str
|
||||
rip_targets: tuple[int, ...]
|
||||
branch_target: int | None
|
||||
|
||||
|
||||
def decode_function(
|
||||
decoder: Any, code: bytes, start: int
|
||||
) -> list[Instruction]:
|
||||
end = start + len(code)
|
||||
instructions = []
|
||||
for address, size, mnemonic, operands in decoder.disasm_lite(code, start):
|
||||
if (mnemonic == "call" or mnemonic.startswith("j")) and operands.startswith("0x"):
|
||||
target = int(operands, 16)
|
||||
if mnemonic == "call":
|
||||
shape = "call"
|
||||
else:
|
||||
shape = mnemonic + (" in" if start <= target < end else " out")
|
||||
instructions.append(Instruction(address, shape, (), target))
|
||||
continue
|
||||
rip_targets = []
|
||||
for match in _RIP_OPERAND.finditer(operands):
|
||||
displacement = int(match.group(2), 0)
|
||||
rip_targets.append(
|
||||
address + size + (-displacement if match.group(1) == "-" else displacement)
|
||||
)
|
||||
operands = _IMMEDIATE.sub(_stable_immediate, _RIP_OPERAND.sub("[rip]", operands))
|
||||
instructions.append(
|
||||
Instruction(address, f"{mnemonic} {operands}", tuple(rip_targets), None)
|
||||
)
|
||||
return instructions
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FunctionInfo:
|
||||
size: int
|
||||
shape_hash: bytes
|
||||
calls: tuple[int, ...]
|
||||
refs: tuple[int, ...]
|
||||
|
||||
|
||||
def function_table(image: Any) -> list[tuple[int, int]]:
|
||||
"""Returns (start, size) for every FDE indexed by `.eh_frame_hdr`."""
|
||||
header = image.sections.get(".eh_frame_hdr")
|
||||
if header is None:
|
||||
raise AnalyzerError("libroblox has no .eh_frame_hdr")
|
||||
raw = image.section_bytes(header)
|
||||
# Version 1, a 4-byte eh_frame_ptr, udata4 count, datarel sdata4 table.
|
||||
if len(raw) < 12 or raw[0] != 1 or raw[2] != 0x03 or raw[3] != 0x3B:
|
||||
raise AnalyzerError(".eh_frame_hdr uses an unsupported encoding")
|
||||
if raw[1] == 0xFF or raw[1] & 0x0F not in (0x03, 0x0B):
|
||||
raise AnalyzerError(".eh_frame_hdr eh_frame_ptr encoding is not 4 bytes")
|
||||
count = struct.unpack_from("<I", raw, 8)[0]
|
||||
if 12 + count * 8 > len(raw):
|
||||
raise AnalyzerError(".eh_frame_hdr table exceeds its section")
|
||||
table = []
|
||||
for index in range(count):
|
||||
location, fde = struct.unpack_from("<ii", raw, 12 + index * 8)
|
||||
start = header.address + location
|
||||
fde_rva = header.address + fde
|
||||
offset = image.rva_to_offset(fde_rva, 16)
|
||||
length, _, begin, size = struct.unpack_from("<IIiI", image.data, offset)
|
||||
# An FDE starts with its length, CIE pointer, then a pcrel sdata4
|
||||
# pc_begin and its udata4 range.
|
||||
if length == 0xFFFFFFFF or fde_rva + 8 + begin != start:
|
||||
raise AnalyzerError(".eh_frame FDE does not match its index entry")
|
||||
table.append((start, size))
|
||||
table.sort()
|
||||
return table
|
||||
|
||||
|
||||
_SOURCE_DIGEST = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:16]
|
||||
|
||||
|
||||
def cache_path(cache_dir: Path, library_sha256: str) -> Path:
|
||||
"""Cache file for one library; shapes depend on this code and capstone."""
|
||||
version = getattr(DERIVER.capstone, "__version__", "unknown")
|
||||
return cache_dir / f"{library_sha256}-{_SOURCE_DIGEST}-capstone{version}.json.gz"
|
||||
|
||||
|
||||
def link_functions(
|
||||
functions: dict[int, FunctionInfo],
|
||||
) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
|
||||
"""Returns each function's callers and each data address's users, once each."""
|
||||
callers: dict[int, list[int]] = collections.defaultdict(list)
|
||||
data_users: dict[int, list[int]] = collections.defaultdict(list)
|
||||
for start, info in functions.items():
|
||||
for target in dict.fromkeys(info.calls):
|
||||
if target in functions:
|
||||
callers[target].append(start)
|
||||
for target in dict.fromkeys(info.refs):
|
||||
data_users[target].append(start)
|
||||
return callers, data_users
|
||||
|
||||
|
||||
def _int_list(value: Any) -> tuple[int, ...]:
|
||||
if not isinstance(value, list) or not all(type(item) is int for item in value):
|
||||
raise ValueError("cached index entry is malformed")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _read_cache(path: Path) -> dict[int, FunctionInfo] | None:
|
||||
"""Returns the cached functions, or None when the file is missing or bad."""
|
||||
try:
|
||||
with gzip.open(path, "rt", encoding="utf-8") as stream:
|
||||
entries = json.load(stream)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ValueError("cached index is empty")
|
||||
functions = {}
|
||||
for entry in entries:
|
||||
start, size, shape_hash, calls, refs = entry
|
||||
if type(start) is not int or type(size) is not int:
|
||||
raise ValueError("cached index entry is malformed")
|
||||
functions[start] = FunctionInfo(
|
||||
size, bytes.fromhex(shape_hash), _int_list(calls), _int_list(refs)
|
||||
)
|
||||
return functions
|
||||
except (OSError, EOFError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(path: Path, functions: dict[int, FunctionInfo]) -> None:
|
||||
# A cache that cannot be written only costs the next run a rebuild.
|
||||
entries = [
|
||||
[start, info.size, info.shape_hash.hex(), list(info.calls), list(info.refs)]
|
||||
for start, info in functions.items()
|
||||
]
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as raw, gzip.open(raw, "wt", encoding="utf-8") as stream:
|
||||
json.dump(entries, stream, separators=(",", ":"))
|
||||
os.replace(temporary, path)
|
||||
except OSError:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class FunctionIndex:
|
||||
"""Normalized shapes, calls and data references of every function.
|
||||
|
||||
With `cache_dir`, the per-function data is kept in a file named after the
|
||||
library's SHA-256, this module's source and the capstone version, and read
|
||||
back instead of decoding the library again.
|
||||
"""
|
||||
|
||||
def __init__(self, image: Any, cache_dir: Path | None = None):
|
||||
DERIVER.require_capstone()
|
||||
self.image = image
|
||||
self.decoder = DERIVER.capstone.Cs(
|
||||
DERIVER.capstone.CS_ARCH_X86, DERIVER.capstone.CS_MODE_64
|
||||
)
|
||||
self.text = image.sections[".text"]
|
||||
self._decoded: dict[int, list[Instruction]] = {}
|
||||
path = None if cache_dir is None else cache_path(Path(cache_dir), image.sha256)
|
||||
functions = None if path is None else _read_cache(path)
|
||||
if functions is None:
|
||||
functions = self._decode_all()
|
||||
if path is not None:
|
||||
_write_cache(path, functions)
|
||||
self.functions = functions
|
||||
self.starts = sorted(self.functions)
|
||||
self.callers, self.data_users = link_functions(self.functions)
|
||||
|
||||
def _decode_all(self) -> dict[int, FunctionInfo]:
|
||||
text_end = self.text.address + self.text.size
|
||||
functions = {}
|
||||
for start, size in function_table(self.image):
|
||||
if size == 0 or not self.text.address <= start < text_end:
|
||||
continue
|
||||
instructions = self.instructions(start, size, cache=False)
|
||||
shape_hash = hashlib.blake2b(
|
||||
"\n".join(item.shape for item in instructions).encode(), digest_size=8
|
||||
).digest()
|
||||
calls = tuple(
|
||||
item.branch_target
|
||||
for item in instructions
|
||||
if item.branch_target is not None
|
||||
and (item.shape == "call" or item.shape.endswith(" out"))
|
||||
)
|
||||
refs = tuple(target for item in instructions for target in item.rip_targets)
|
||||
functions[start] = FunctionInfo(size, shape_hash, calls, refs)
|
||||
return functions
|
||||
|
||||
def instructions(
|
||||
self, start: int, size: int | None = None, cache: bool = True
|
||||
) -> list[Instruction]:
|
||||
if cache and start in self._decoded:
|
||||
return self._decoded[start]
|
||||
if size is None:
|
||||
size = self.functions[start].size
|
||||
offset = self.text.offset + start - self.text.address
|
||||
decoded = decode_function(self.decoder, self.image.data[offset : offset + size], start)
|
||||
if cache:
|
||||
self._decoded[start] = decoded
|
||||
return decoded
|
||||
|
||||
def shapes(self, start: int) -> list[str]:
|
||||
# Scoring reads each candidate once; only aligned functions stay cached.
|
||||
return [item.shape for item in self.instructions(start, cache=start in self._decoded)]
|
||||
|
||||
def containing(self, rva: int) -> int | None:
|
||||
index = bisect.bisect_right(self.starts, rva) - 1
|
||||
if index < 0:
|
||||
return None
|
||||
start = self.starts[index]
|
||||
return start if rva < start + self.functions[start].size else None
|
||||
|
||||
def string_at(self, rva: int) -> bytes | None:
|
||||
segment = self.image.segment_for_rva(rva)
|
||||
if segment is None or not segment.contains_file_data(rva):
|
||||
return None
|
||||
offset = self.image.rva_to_offset(rva)
|
||||
raw = self.image.data[offset : offset + 96]
|
||||
end = raw.find(b"\0")
|
||||
if end < 4 or not all(32 <= byte < 127 for byte in raw[:end]):
|
||||
return None
|
||||
return raw[:end]
|
||||
@@ -6,8 +6,9 @@
|
||||
Usage: summarize_profile_trace.py TRACE [TRACE ...]
|
||||
|
||||
Each trace gets its own column, so `before.json after.json` compares them.
|
||||
Frame times come from the "frame interval (ms)" counter; every other row is
|
||||
a slice name recorded by the Vulkan adapter.
|
||||
Frame times come from the "frame interval (ms)" counter, peaks from the
|
||||
texture counters; every other row is a slice name recorded by the Vulkan
|
||||
adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +21,7 @@ import sys
|
||||
from typing import Iterable
|
||||
|
||||
FRAME_COUNTER = "frame interval (ms)"
|
||||
PEAK_COUNTERS = ("emulated texture (MB)", "compressed texture (MB)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -37,6 +39,7 @@ class Distribution:
|
||||
class Summary:
|
||||
frames: Distribution | None
|
||||
slices: dict[str, Distribution]
|
||||
peaks: dict[str, float]
|
||||
|
||||
|
||||
def _nearest_rank(ordered: list[float], percent: float) -> float:
|
||||
@@ -57,16 +60,21 @@ def _distribution(values: list[float]) -> Distribution:
|
||||
def summarize(events: Iterable[dict]) -> Summary:
|
||||
frames: list[float] = []
|
||||
slices: dict[str, list[float]] = {}
|
||||
peaks: dict[str, float] = {}
|
||||
for event in events:
|
||||
phase = event.get("ph")
|
||||
if phase == "C" and event.get("name") == FRAME_COUNTER:
|
||||
frames.append(float(event["args"]["value"]))
|
||||
elif phase == "C" and event.get("name") in PEAK_COUNTERS:
|
||||
value = float(event["args"]["value"])
|
||||
peaks[event["name"]] = max(peaks.get(event["name"], value), value)
|
||||
elif phase == "X":
|
||||
slices.setdefault(event["name"], []).append(event["dur"] / 1000)
|
||||
return Summary(
|
||||
frames=_distribution(frames) if frames else None,
|
||||
slices={name: _distribution(values)
|
||||
for name, values in slices.items()})
|
||||
for name, values in slices.items()},
|
||||
peaks=peaks)
|
||||
|
||||
|
||||
def _row(label: str, cells: list[str]) -> str:
|
||||
@@ -96,6 +104,11 @@ def to_markdown(traces: list[tuple[str, Summary]]) -> str:
|
||||
if any(frames):
|
||||
lines += [_metric_row(f"frame interval {field} (ms)", frames, field)
|
||||
for field in ("mean", "p50", "p95", "p99", "max")]
|
||||
for name in PEAK_COUNTERS:
|
||||
if any(name in summary.peaks for summary in summaries):
|
||||
lines.append(_row(f"{name} peak", [
|
||||
f"{summary.peaks[name]:.2f}" if name in summary.peaks else "-"
|
||||
for summary in summaries]))
|
||||
|
||||
# Largest total time first, taken from whichever trace spent the most.
|
||||
totals: dict[str, float] = {}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#version 450
|
||||
// One triangle that covers the viewport; uv spans 0..1 over the target.
|
||||
layout(location = 0) out vec2 uv;
|
||||
|
||||
void main() {
|
||||
uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||
gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#version 450
|
||||
// FXAA for one VR eye, after Timothy Lottes' FXAA (console variant). The eye
|
||||
// holds sRGB-encoded bytes, so luma is computed on perceptual values, which
|
||||
// is what FXAA expects.
|
||||
layout(set = 0, binding = 0) uniform sampler2D source;
|
||||
layout(push_constant) uniform Push {
|
||||
vec2 texel; // 1 / source extent
|
||||
uint linearize; // 1 when the target view re-encodes to sRGB
|
||||
} push;
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 color;
|
||||
|
||||
const float kEdgeThreshold = 1.0 / 8.0;
|
||||
const float kEdgeThresholdMin = 1.0 / 24.0;
|
||||
const float kReduceMul = 1.0 / 8.0;
|
||||
const float kReduceMin = 1.0 / 128.0;
|
||||
const float kSpanMax = 8.0;
|
||||
|
||||
float Luma(vec3 rgb) { return dot(rgb, vec3(0.299, 0.587, 0.114)); }
|
||||
|
||||
vec3 Sample(vec2 at) { return textureLod(source, at, 0.0).rgb; }
|
||||
|
||||
// The target encodes linear values back to sRGB, which restores these bytes.
|
||||
vec3 Linearize(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)),
|
||||
step(vec3(0.04045), c));
|
||||
}
|
||||
|
||||
void main() {
|
||||
const vec4 center = textureLod(source, uv, 0.0);
|
||||
const float lumaM = Luma(center.rgb);
|
||||
const float lumaNW = Luma(Sample(uv + vec2(-1.0, -1.0) * push.texel));
|
||||
const float lumaNE = Luma(Sample(uv + vec2(1.0, -1.0) * push.texel));
|
||||
const float lumaSW = Luma(Sample(uv + vec2(-1.0, 1.0) * push.texel));
|
||||
const float lumaSE = Luma(Sample(uv + vec2(1.0, 1.0) * push.texel));
|
||||
const float lumaMin =
|
||||
min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
|
||||
const float lumaMax =
|
||||
max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));
|
||||
|
||||
vec3 rgb = center.rgb;
|
||||
// Flat areas, where nothing aliases, keep their exact pixels.
|
||||
if (lumaMax - lumaMin >= max(kEdgeThresholdMin, lumaMax * kEdgeThreshold)) {
|
||||
vec2 dir = vec2(-((lumaNW + lumaNE) - (lumaSW + lumaSE)),
|
||||
(lumaNW + lumaSW) - (lumaNE + lumaSE));
|
||||
const float reduce = max((lumaNW + lumaNE + lumaSW + lumaSE) *
|
||||
(0.25 * kReduceMul),
|
||||
kReduceMin);
|
||||
const float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce);
|
||||
dir = clamp(dir * rcpDirMin, vec2(-kSpanMax), vec2(kSpanMax)) * push.texel;
|
||||
const vec3 rgbA = 0.5 * (Sample(uv + dir * (1.0 / 3.0 - 0.5)) +
|
||||
Sample(uv + dir * (2.0 / 3.0 - 0.5)));
|
||||
const vec3 rgbB =
|
||||
rgbA * 0.5 + 0.25 * (Sample(uv + dir * -0.5) + Sample(uv + dir * 0.5));
|
||||
const float lumaB = Luma(rgbB);
|
||||
rgb = (lumaB < lumaMin || lumaB > lumaMax) ? rgbA : rgbB;
|
||||
}
|
||||
color = vec4(push.linearize != 0u ? Linearize(rgb) : rgb, center.a);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
#include "smaa_common.glsl"
|
||||
#define SMAA_INCLUDE_VS 0
|
||||
#include "SMAA.hlsl"
|
||||
layout(set = 0, binding = 0) uniform sampler2D colorTex;
|
||||
layout(set = 0, binding = 1) uniform sampler2D blendTex;
|
||||
layout(location = 0) in vec2 texcoord;
|
||||
layout(location = 1) in vec4 offset;
|
||||
layout(location = 0) out vec4 color;
|
||||
|
||||
// The target encodes linear values back to sRGB, which restores these bytes.
|
||||
vec3 Linearize(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)),
|
||||
step(vec3(0.04045), c));
|
||||
}
|
||||
|
||||
void main() {
|
||||
color = SMAANeighborhoodBlendingPS(texcoord, offset, colorTex, blendTex);
|
||||
if (push.linearize != 0u) color.rgb = Linearize(color.rgb);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
#include "smaa_common.glsl"
|
||||
#define SMAA_INCLUDE_PS 0
|
||||
#include "SMAA.hlsl"
|
||||
layout(location = 0) out vec2 texcoord;
|
||||
layout(location = 1) out vec4 offset;
|
||||
|
||||
void main() {
|
||||
texcoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||
gl_Position = vec4(texcoord * 2.0 - 1.0, 0.0, 1.0);
|
||||
SMAANeighborhoodBlendingVS(texcoord, offset);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Shared setup for the SMAA passes: the upstream SMAA.hlsl (third_party/smaa)
|
||||
// in its GLSL 4 mode at the High preset. Vulkan, like Direct3D, puts the
|
||||
// texture origin at the top left, which is what SMAA and its lookup textures
|
||||
// expect, so nothing is flipped.
|
||||
layout(push_constant) uniform Push {
|
||||
vec4 metrics; // 1/width, 1/height, width, height
|
||||
uint linearize; // 1 when the target view re-encodes to sRGB
|
||||
} push;
|
||||
#define SMAA_RT_METRICS push.metrics
|
||||
#define SMAA_GLSL_4 1
|
||||
#define SMAA_PRESET_HIGH 1
|
||||
@@ -0,0 +1,12 @@
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
#include "smaa_common.glsl"
|
||||
#define SMAA_INCLUDE_VS 0
|
||||
#include "SMAA.hlsl"
|
||||
// Luma edges on the eye's sRGB-encoded bytes, as SMAA recommends.
|
||||
layout(set = 0, binding = 0) uniform sampler2D colorTex;
|
||||
layout(location = 0) in vec2 texcoord;
|
||||
layout(location = 1) in vec4 offset[3];
|
||||
layout(location = 0) out vec2 edges;
|
||||
|
||||
void main() { edges = SMAALumaEdgeDetectionPS(texcoord, offset, colorTex); }
|
||||
@@ -0,0 +1,13 @@
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
#include "smaa_common.glsl"
|
||||
#define SMAA_INCLUDE_PS 0
|
||||
#include "SMAA.hlsl"
|
||||
layout(location = 0) out vec2 texcoord;
|
||||
layout(location = 1) out vec4 offset[3];
|
||||
|
||||
void main() {
|
||||
texcoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||
gl_Position = vec4(texcoord * 2.0 - 1.0, 0.0, 1.0);
|
||||
SMAAEdgeDetectionVS(texcoord, offset);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
#include "smaa_common.glsl"
|
||||
#define SMAA_INCLUDE_VS 0
|
||||
#include "SMAA.hlsl"
|
||||
layout(set = 0, binding = 0) uniform sampler2D edgesTex;
|
||||
layout(set = 0, binding = 1) uniform sampler2D areaTex;
|
||||
layout(set = 0, binding = 2) uniform sampler2D searchTex;
|
||||
layout(location = 0) in vec2 texcoord;
|
||||
layout(location = 1) in vec2 pixcoord;
|
||||
layout(location = 2) in vec4 offset[3];
|
||||
layout(location = 0) out vec4 weights;
|
||||
|
||||
void main() {
|
||||
weights = SMAABlendingWeightCalculationPS(texcoord, pixcoord, offset, edgesTex,
|
||||
areaTex, searchTex, vec4(0.0));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
#include "smaa_common.glsl"
|
||||
#define SMAA_INCLUDE_PS 0
|
||||
#include "SMAA.hlsl"
|
||||
layout(location = 0) out vec2 texcoord;
|
||||
layout(location = 1) out vec2 pixcoord;
|
||||
layout(location = 2) out vec4 offset[3];
|
||||
|
||||
void main() {
|
||||
texcoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||
gl_Position = vec4(texcoord * 2.0 - 1.0, 0.0, 1.0);
|
||||
SMAABlendingWeightCalculationVS(texcoord, pixcoord, offset);
|
||||
}
|
||||
+123
-43
@@ -5,52 +5,132 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Nightcap for Linux</title>
|
||||
<title>Nightcap: Roblox on Linux</title>
|
||||
<meta name="description" content="Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.">
|
||||
<meta name="google-site-verification" content="vznL8W2txRlmfwhGg7ZBPSxQaPV9OtaIUoYPvmd9aUA">
|
||||
<link rel="canonical" href="https://coderdayton.github.io/nightcap/">
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Nightcap">
|
||||
<meta property="og:title" content="Nightcap: Roblox on Linux">
|
||||
<meta property="og:description" content="Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.">
|
||||
<meta property="og:url" content="https://coderdayton.github.io/nightcap/">
|
||||
<meta property="og:image" content="https://coderdayton.github.io/nightcap/preview.png">
|
||||
<meta property="og:image:alt" content="Roblox running on Linux with Nightcap">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Nightcap",
|
||||
"description": "Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.",
|
||||
"applicationCategory": "GameApplication",
|
||||
"operatingSystem": "Linux",
|
||||
"url": "https://coderdayton.github.io/nightcap/",
|
||||
"downloadUrl": "https://github.com/CoderDayton/nightcap/releases/latest",
|
||||
"license": "https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<img src="nightcap.svg" alt="Nightcap logo">
|
||||
<h1>Nightcap</h1>
|
||||
<p>Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
|
||||
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
|
||||
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
|
||||
<div class="commands">
|
||||
<p>AppImage</p>
|
||||
<code>chmod +x Nightcap-x86_64.AppImage
|
||||
./Nightcap-x86_64.AppImage</code>
|
||||
<p>Flatpak</p>
|
||||
<code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
||||
flatpak run io.github.CoderDayton.nightcap</code>
|
||||
<p>DEB, RPM and pacman</p>
|
||||
<code>sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="wrap">
|
||||
<p class="top">
|
||||
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||
</p>
|
||||
<p class="eyebrow">Roblox on Linux, tuned for desktop play</p>
|
||||
<h1><img src="nightcap.svg" alt=""><span>Nightcap</span></h1>
|
||||
<p class="lede">A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.</p>
|
||||
<p class="actions">
|
||||
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
|
||||
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
<section class="row stacked">
|
||||
<h2>Install</h2>
|
||||
<dl class="body install">
|
||||
<dt>AppImage</dt>
|
||||
<dd><pre><code>chmod +x Nightcap-x86_64.AppImage
|
||||
./Nightcap-x86_64.AppImage</code></pre></dd>
|
||||
<dt>Flatpak</dt>
|
||||
<dd><pre><code>flatpak install --user https://coderdayton.github.io/nightcap/nightcap.flatpakref
|
||||
flatpak run io.github.CoderDayton.nightcap</code></pre></dd>
|
||||
<dt>DEB, RPM, pacman</dt>
|
||||
<dd><pre><code>sudo apt install ./nightcap_<version>-1_amd64.deb
|
||||
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst</code>
|
||||
<p>Build from source</p>
|
||||
<code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst</code></pre></dd>
|
||||
<dt>Source</dt>
|
||||
<dd><pre><code>git clone --recurse-submodules https://github.com/CoderDayton/nightcap.git
|
||||
cd nightcap && make build
|
||||
./build/mocktail</code>
|
||||
</div>
|
||||
<h2>What's different</h2>
|
||||
<ul>
|
||||
<li>Lower input lag. Frames are shown as soon as they are ready.</li>
|
||||
<li>Sharper textures, with your own PNG overrides if you want them.</li>
|
||||
<li>ETC2 textures render correctly on desktop GPUs.</li>
|
||||
<li>No more error 319 kicks after joining a server.</li>
|
||||
<li>Roblox sees your real RAM and screen size.</li>
|
||||
</ul>
|
||||
<h2>Direct downloads</h2>
|
||||
<p>Prebuilt builds for Linux x86_64.</p>
|
||||
<p class="links">
|
||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">AppImage</a>
|
||||
<a href="Nightcap-x86_64.flatpak">Flatpak bundle</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap/releases/latest">DEB, RPM, pacman</a>
|
||||
<a href="nightcap-flatpak.gpg">Signing key</a>
|
||||
<a href="terms.html">Terms</a>
|
||||
<a href="privacy.html">Privacy</a>
|
||||
</p>
|
||||
<p><a class="source" href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
||||
<a class="source" href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a></p>
|
||||
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
|
||||
</main></body>
|
||||
./build/mocktail</code></pre></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="row">
|
||||
<h2>What's different</h2>
|
||||
<dl class="body facts">
|
||||
<dt>Roblox updates</dt>
|
||||
<dd>New Roblox versions work soon after they come out.</dd>
|
||||
<dt>More games</dt>
|
||||
<dd>Answers a server check some experiences use, so you can play those too.</dd>
|
||||
<dt>Voice chat</dt>
|
||||
<dd>Your microphone still works after a Roblox update.</dd>
|
||||
<dt>Login</dt>
|
||||
<dd>Verification checks and captchas finish instead of hanging.</dd>
|
||||
<dt>Look</dt>
|
||||
<dd>The new home screen and side menu, like on Windows.</dd>
|
||||
<dt>Your own assets</dt>
|
||||
<dd>Drop in your own cursors, fonts and skies. The folder layout matches Sober's.</dd>
|
||||
<dt>Mouse and CPU</dt>
|
||||
<dd>Optional raw mouse input, and less CPU use while idle.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="row">
|
||||
<h2>Direct downloads</h2>
|
||||
<div class="body">
|
||||
<p>Prebuilt builds for Linux x86_64.</p>
|
||||
<ul class="links">
|
||||
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage"><span class="name">AppImage</span><span class="file">Nightcap-x86_64.AppImage</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||
<li><a href="Nightcap-x86_64.flatpak"><span class="name">Flatpak bundle</span><span class="file">Nightcap-x86_64.flatpak</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||
<li><a href="https://github.com/CoderDayton/nightcap/releases/latest"><span class="name">DEB, RPM, pacman</span><span class="file">GitHub releases page</span><span class="go" aria-hidden="true">↗</span></a></li>
|
||||
<li><a href="nightcap-flatpak.gpg"><span class="name">Signing key</span><span class="file">nightcap-flatpak.gpg</span><span class="go" aria-hidden="true">↓</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="wrap foot">
|
||||
<p>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a> ·
|
||||
<a href="https://github.com/komaruworld/mocktail">Upstream Mocktail</a> ·
|
||||
<a href="terms.html">Terms</a> ·
|
||||
<a href="privacy.html">Privacy</a>
|
||||
</p>
|
||||
<p>Nightcap is an independent community project and is not affiliated with Roblox Corporation.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Adds a copy button to each command block. The page works without it.
|
||||
document.querySelectorAll('.install dd').forEach(function (block) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'copy';
|
||||
button.addEventListener('click', function () {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(block.querySelector('code').textContent).then(function () {
|
||||
button.textContent = 'copied';
|
||||
setTimeout(function () { button.textContent = 'copy'; }, 1600);
|
||||
});
|
||||
});
|
||||
block.appendChild(button);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 419 KiB |
+36
-26
@@ -8,36 +8,46 @@
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
||||
<body>
|
||||
<header class="wrap bar">
|
||||
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
|
||||
<p class="top">
|
||||
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h2>What Nightcap collects</h2>
|
||||
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
||||
<main class="wrap prose">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Nightcap is a Linux runtime for the Roblox Android client. This policy covers Nightcap itself and its Discord application. Roblox and Discord have their own policies for the data they handle.</p>
|
||||
|
||||
<h2>What stays on your machine</h2>
|
||||
<ul>
|
||||
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
||||
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
||||
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
||||
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
||||
</ul>
|
||||
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
||||
<h2>What Nightcap collects</h2>
|
||||
<p>Nothing. Nightcap has no server. It does not collect, store, or send telemetry, analytics, crash reports, or account data to the Nightcap project.</p>
|
||||
|
||||
<h2>Discord Rich Presence</h2>
|
||||
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
||||
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
||||
<h2>What stays on your machine</h2>
|
||||
<ul>
|
||||
<li>Configuration in <code>~/.config/mocktail/</code></li>
|
||||
<li>Session logs and support bundles in <code>~/.local/state/mocktail/</code></li>
|
||||
<li>Roblox login session under <code>~/.local/share/mocktail/</code></li>
|
||||
<li>Roblox cache, downloaded payloads, and shader cache under <code>~/.cache/mocktail/</code></li>
|
||||
</ul>
|
||||
<p>Flatpak installs keep the same layout under <code>~/.var/app/io.github.CoderDayton.nightcap/</code>.</p>
|
||||
|
||||
<h2>Roblox</h2>
|
||||
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
||||
<h2>Discord Rich Presence</h2>
|
||||
<p>When Rich Presence is on, Nightcap sends the current experience name, its public icon URL, elapsed time, and an optional public server join code to the Discord desktop client on the same machine over a local socket. Discord displays this on your profile under its own privacy settings. Nightcap never signs in to Discord, never reads a Discord token, and never contacts Discord's servers.</p>
|
||||
<p>Set <code>integrations.discord_rpc.enabled: false</code> in <code>config.yaml</code> to turn it off.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Open an issue at <a class="source" href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
||||
<h2>Roblox</h2>
|
||||
<p>Nightcap talks to Roblox's services exactly as the Roblox client would. That traffic is covered by the Roblox Privacy Policy.</p>
|
||||
|
||||
<p class="links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="terms.html">Terms of Service</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main></body>
|
||||
<h2>Contact</h2>
|
||||
<p>Open an issue at <a href="https://github.com/CoderDayton/nightcap/issues">github.com/CoderDayton/nightcap/issues</a>.</p>
|
||||
|
||||
<p class="legal-nav">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="terms.html">Terms of Service</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+260
-49
@@ -1,94 +1,305 @@
|
||||
/* Modified by vii from komaruworld/mocktail. See README "About this fork". */
|
||||
:root {
|
||||
font: 17px/1.55 system-ui, sans-serif;
|
||||
--bg: #14122b;
|
||||
--ink: #ece8ff;
|
||||
--muted: #b3aad6;
|
||||
--accent: #6c4fd1;
|
||||
--link: #a78bfa;
|
||||
--line: #3a3470;
|
||||
--code-bg: #1d1a3d;
|
||||
font: 17px/1.6 system-ui, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #14122b;
|
||||
color: #ece8ff;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 680px;
|
||||
margin: 12vh auto;
|
||||
padding: 32px;
|
||||
a { color: var(--link); }
|
||||
a:focus-visible,
|
||||
button:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
.hero { padding-bottom: clamp(48px, 10vh, 110px); }
|
||||
|
||||
.top {
|
||||
margin: 0;
|
||||
padding-top: 22px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: .2rem 0;
|
||||
font-size: clamp(2.5rem, 8vw, 4.5rem);
|
||||
.top a {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 32px 0 0;
|
||||
font-size: 1.15rem;
|
||||
.top a:hover { color: var(--ink); }
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin: clamp(48px, 12vh, 130px) 0 0;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--code-bg);
|
||||
color: var(--link);
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
color: #b3aad6;
|
||||
.hero h1 {
|
||||
margin: 18px 0 0;
|
||||
font-size: clamp(3.6rem, 15vw, 9.5rem);
|
||||
font-weight: 800;
|
||||
line-height: .95;
|
||||
letter-spacing: -.045em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .16em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.2rem;
|
||||
.hero h1 img {
|
||||
height: .78em;
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 34ch;
|
||||
margin: 22px 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.actions { margin: 28px 0 0; }
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
margin: 16px 8px 16px 0;
|
||||
padding: 12px 18px;
|
||||
border-radius: 9px;
|
||||
background: #6c4fd1;
|
||||
color: white;
|
||||
margin: 0 10px 10px 0;
|
||||
padding: 12px 20px;
|
||||
border: 2px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
background: #2f2a6b;
|
||||
a.button:hover { background: #7d62dd; border-color: #7d62dd; }
|
||||
|
||||
a.button.secondary {
|
||||
background: var(--bg);
|
||||
border-color: var(--link);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.commands {
|
||||
margin-top: 10px;
|
||||
a.button.secondary:hover { background: var(--code-bg); }
|
||||
|
||||
/* Every section is a label on the left and its content on the right. */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 8px 32px;
|
||||
padding: 36px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.commands p,
|
||||
.download-label {
|
||||
margin: 14px 0 5px;
|
||||
color: #ece8ff;
|
||||
font-size: .82rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row.stacked { grid-template-columns: 1fr; gap: 20px; }
|
||||
|
||||
.body { margin: 0; min-width: 0; }
|
||||
.body p { margin: 0 0 12px; color: var(--muted); }
|
||||
|
||||
dl.body {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0 28px;
|
||||
}
|
||||
|
||||
dt {
|
||||
padding: 9px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 9px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.facts dt,
|
||||
.facts dd { border-bottom: 1px solid var(--line); }
|
||||
.facts dt:last-of-type,
|
||||
.facts dd:last-of-type { border-bottom: 0; }
|
||||
.facts dt { color: var(--ink); font-weight: 700; }
|
||||
|
||||
dl.body.install { display: block; }
|
||||
|
||||
.install dt {
|
||||
padding: 18px 0 6px;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.install dt:first-child { padding-top: 0; }
|
||||
.install dd { padding: 0; }
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid #3a3470;
|
||||
border-radius: 6px;
|
||||
background: #1d1a3d;
|
||||
font: .9rem/1.7 ui-monospace, "DejaVu Sans Mono", "JetBrains Mono", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
dd button {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: 0;
|
||||
padding: 2px 0 2px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: .8rem ui-monospace, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
dd button:hover { color: var(--ink); text-decoration: underline; }
|
||||
|
||||
.links {
|
||||
margin: 10px 0 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.links li { border-bottom: 1px solid var(--line); }
|
||||
|
||||
.links a {
|
||||
display: inline-block;
|
||||
margin: 0 14px 6px 0;
|
||||
color: #a78bfa;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 24px;
|
||||
align-items: baseline;
|
||||
gap: 4px 16px;
|
||||
margin: 0 -8px;
|
||||
padding: 12px 24px 12px 8px;
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.source {
|
||||
color: #a78bfa;
|
||||
.links a:hover { background: var(--code-bg); }
|
||||
.links a:hover .name { text-decoration: underline; text-decoration-color: var(--link); text-underline-offset: 4px; }
|
||||
|
||||
.links .file {
|
||||
color: var(--muted);
|
||||
font: 400 .88rem ui-monospace, "DejaVu Sans Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.links .go { color: var(--link); text-align: right; }
|
||||
|
||||
.foot {
|
||||
padding-bottom: 48px;
|
||||
color: var(--muted);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.foot::before {
|
||||
content: "";
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.foot p { margin: 0 0 8px; }
|
||||
|
||||
/* Terms and privacy pages: a header bar and one readable column. */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.bar .top { padding-top: 0; }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand img { width: 30px; height: 30px; }
|
||||
|
||||
.prose {
|
||||
padding-top: clamp(32px, 8vh, 80px);
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.prose > * { max-width: 700px; }
|
||||
|
||||
.prose h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin: 36px 0 6px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.prose p,
|
||||
.prose li { margin: 0 0 12px; }
|
||||
.prose ul { margin: 0 0 12px; padding-left: 1.2rem; }
|
||||
|
||||
.prose code {
|
||||
padding: 1px 6px;
|
||||
background: var(--code-bg);
|
||||
font-size: .85rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.legal-nav { display: flex; flex-wrap: wrap; gap: 4px 20px; margin-top: 28px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row { grid-template-columns: 1fr; padding: 28px 0; }
|
||||
dl.body:not(.install) { grid-template-columns: 1fr; }
|
||||
.facts dt { padding-bottom: 0; border-bottom: 0; }
|
||||
.links a { grid-template-columns: 1fr 24px; }
|
||||
.links .file { grid-column: 1; grid-row: 2; }
|
||||
.links .go { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
|
||||
}
|
||||
+29
-19
@@ -8,29 +8,39 @@
|
||||
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
||||
<body>
|
||||
<header class="wrap bar">
|
||||
<a class="brand" href="index.html"><img src="nightcap.svg" alt="" width="30" height="30"><span>Nightcap</span></a>
|
||||
<p class="top">
|
||||
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
|
||||
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h2>No warranty</h2>
|
||||
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
||||
<main class="wrap prose">
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Nightcap is free and open-source software licensed under the Apache License, Version 2.0. The license text shipped with the source governs your use of the software.</p>
|
||||
|
||||
<h2>Not affiliated</h2>
|
||||
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
||||
<h2>No warranty</h2>
|
||||
<p>Nightcap is provided "as is", without warranty of any kind. The authors are not liable for any loss, damage, or account action that results from using it. See sections 7 and 8 of the Apache License.</p>
|
||||
|
||||
<h2>Your responsibility</h2>
|
||||
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
||||
<h2>Not affiliated</h2>
|
||||
<p>Nightcap is an independent community project. It is not affiliated with, endorsed by, or supported by Roblox Corporation or Discord Inc. Roblox and Discord are trademarks of their respective owners.</p>
|
||||
|
||||
<h2>Discord application</h2>
|
||||
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
||||
<h2>Your responsibility</h2>
|
||||
<p>You are responsible for following the Roblox Terms of Use and the Discord Terms of Service while using Nightcap with those services. Nightcap does not modify Roblox game logic, does not bypass Roblox security, and is not a cheat.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
||||
<h2>Discord application</h2>
|
||||
<p>The Nightcap Discord application only provides Rich Presence for the Nightcap desktop runtime. It requests no permissions, has no bot, and does not access your Discord account.</p>
|
||||
|
||||
<p class="links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="privacy.html">Privacy Policy</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main></body>
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may change with new releases. The current version always lives at this address.</p>
|
||||
|
||||
<p class="legal-nav">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="privacy.html">Privacy Policy</a>
|
||||
<a href="https://github.com/CoderDayton/nightcap">Source code</a>
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -31,18 +32,18 @@ struct NativeOptionalFormat {
|
||||
struct NativeSharedSink { void* object; void* owner; };
|
||||
static_assert(sizeof(NativeOptionalFormat) == 16);
|
||||
static_assert(sizeof(NativePcmFormat) == 8);
|
||||
inline std::atomic<NativeInputCapture*> g_native_capture{nullptr};
|
||||
inline std::shared_ptr<NativeInputCapture> g_native_capture;
|
||||
|
||||
class NativeInputCapture {
|
||||
public:
|
||||
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
|
||||
std::uint64_t, const NativePcmFormat*);
|
||||
using Destroy = void (*)(NativeSharedSink*);
|
||||
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
|
||||
using ImageRange = bool (*)(std::uintptr_t, std::uintptr_t, std::size_t);
|
||||
struct SinkAbi {
|
||||
std::uintptr_t vtable;
|
||||
DeliverPcm deliver;
|
||||
Destroy release;
|
||||
std::uintptr_t image_base;
|
||||
ImageRange is_image_relro;
|
||||
ImageRange is_image_code;
|
||||
OriginalLatency latency;
|
||||
};
|
||||
NativeInputCapture(SinkAbi abi, bool enabled)
|
||||
@@ -50,17 +51,22 @@ class NativeInputCapture {
|
||||
~NativeInputCapture() { Shutdown(); }
|
||||
|
||||
static NativeOptionalFormat Format(void*) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
return {{}, capture && capture->enabled_ ? 1ULL : 0ULL};
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (!capture) return {};
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
return {{}, capture->enabled_ && !capture->closed_ ? 1ULL : 0ULL};
|
||||
}
|
||||
static void Latency(void* self, std::uint32_t* playback,
|
||||
std::uint32_t* recording) {
|
||||
// Preserve the native playback latency and report the host capture queue.
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (!capture) return;
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
if (capture->closed_) return;
|
||||
capture->abi_.latency(self, playback, recording);
|
||||
if (recording) {
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
*recording = 10;
|
||||
auto it = capture->sessions_.find(self);
|
||||
if (it != capture->sessions_.end()) {
|
||||
@@ -70,33 +76,48 @@ class NativeInputCapture {
|
||||
}
|
||||
}
|
||||
static bool Start(void* self, NativeSharedSink* sink) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
return capture && capture->StartSink(self, sink);
|
||||
}
|
||||
static void Stop(void* self, NativeSharedSink* sink) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (capture) capture->StopSink(self, sink);
|
||||
}
|
||||
static bool Recording(void* self) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (!capture) return false;
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
return capture->sessions_.find(self) != capture->sessions_.end();
|
||||
}
|
||||
static void Poll(void* self) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
auto capture =
|
||||
std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire);
|
||||
if (capture) capture->Deliver(self);
|
||||
}
|
||||
void Shutdown() {
|
||||
std::lock_guard lock(mutex_);
|
||||
closed_ = true;
|
||||
for (auto& [self, session] : sessions_) Close(*session);
|
||||
sessions_.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sink {
|
||||
NativeSharedSink shared;
|
||||
DeliverPcm deliver;
|
||||
};
|
||||
struct SharedCount {
|
||||
const std::uintptr_t* vtable;
|
||||
long shared_owners;
|
||||
long weak_owners;
|
||||
};
|
||||
using ControlBlockHook = void (*)(void*);
|
||||
struct Session {
|
||||
std::unique_ptr<AudioCapture> input;
|
||||
std::vector<NativeSharedSink> sinks;
|
||||
std::vector<Sink> sinks;
|
||||
std::mutex queue_mutex;
|
||||
// At most 200 ms; a stalled consumer must not accumulate stale speech.
|
||||
std::array<std::array<std::int16_t, 480>, 20> frames{};
|
||||
@@ -118,20 +139,33 @@ class NativeInputCapture {
|
||||
session.input->buffer_data(), bytes);
|
||||
++session.count;
|
||||
}
|
||||
bool IsGuestVtable(const void* object, std::size_t size,
|
||||
std::initializer_list<std::size_t> methods) const {
|
||||
const auto base = abi_.image_base;
|
||||
const auto* table = *static_cast<const std::uintptr_t* const*>(object);
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(table);
|
||||
if (address < base ||
|
||||
!abi_.is_image_relro(base, address - base, size * sizeof(*table)))
|
||||
return false;
|
||||
for (const auto slot : methods)
|
||||
if (table[slot] < base ||
|
||||
!abi_.is_image_code(base, table[slot] - base, 1))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
bool StartSink(void* self, NativeSharedSink* sink) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!enabled_ || !self || !sink || !sink->object || !sink->owner)
|
||||
if (closed_ || !enabled_ || !self || !sink || !sink->object || !sink->owner)
|
||||
return false;
|
||||
auto* table = *reinterpret_cast<std::uintptr_t**>(sink->object);
|
||||
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
|
||||
table[2] != reinterpret_cast<std::uintptr_t>(abi_.deliver)) {
|
||||
if (!IsGuestVtable(sink->object, 3, {2}) ||
|
||||
!IsGuestVtable(sink->owner, 5, {2, 4})) {
|
||||
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
|
||||
return false;
|
||||
}
|
||||
auto it = sessions_.find(self);
|
||||
if (it != sessions_.end()) {
|
||||
for (const auto& existing : it->second->sinks)
|
||||
if (existing.object == sink->object) return true;
|
||||
if (existing.shared.object == sink->object) return true;
|
||||
} else {
|
||||
auto session = std::make_unique<Session>();
|
||||
SdlAudioCaptureOptions options;
|
||||
@@ -151,13 +185,20 @@ class NativeInputCapture {
|
||||
}
|
||||
// The native parameter is a by-value shared_ptr. Retain its existing
|
||||
// ownership by moving it, leaving the caller's temporary empty.
|
||||
it->second->sinks.push_back(*sink);
|
||||
const auto* table = *static_cast<const std::uintptr_t* const*>(sink->object);
|
||||
it->second->sinks.push_back({*sink, reinterpret_cast<DeliverPcm>(table[2])});
|
||||
*sink = {};
|
||||
return true;
|
||||
}
|
||||
void Release(NativeSharedSink& sink) {
|
||||
abi_.release(&sink);
|
||||
static void Release(Sink& sink) {
|
||||
auto* count = static_cast<SharedCount*>(sink.shared.owner);
|
||||
sink = {};
|
||||
if (__atomic_fetch_sub(&count->shared_owners, 1, __ATOMIC_ACQ_REL) != 0)
|
||||
return;
|
||||
reinterpret_cast<ControlBlockHook>(count->vtable[2])(count);
|
||||
if (__atomic_load_n(&count->weak_owners, __ATOMIC_ACQUIRE) == 0 ||
|
||||
__atomic_fetch_sub(&count->weak_owners, 1, __ATOMIC_ACQ_REL) == 0)
|
||||
reinterpret_cast<ControlBlockHook>(count->vtable[4])(count);
|
||||
}
|
||||
void Close(Session& session) {
|
||||
session.input->Shutdown();
|
||||
@@ -174,7 +215,7 @@ class NativeInputCapture {
|
||||
if (it == sessions_.end() || !sink) return;
|
||||
auto& session = *it->second;
|
||||
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
|
||||
[&](const auto& entry) { return entry.object == sink->object; });
|
||||
[&](const auto& entry) { return entry.shared.object == sink->object; });
|
||||
if (existing == session.sinks.end()) return;
|
||||
Release(*existing);
|
||||
session.sinks.erase(existing);
|
||||
@@ -203,8 +244,7 @@ class NativeInputCapture {
|
||||
}
|
||||
const NativePcmFormat format;
|
||||
for (const auto& sink : session.sinks)
|
||||
abi_.deliver(
|
||||
sink.object, pcm.data(), pcm.size(), delay, &format);
|
||||
sink.deliver(sink.shared.object, pcm.data(), pcm.size(), delay, &format);
|
||||
if (++session.delivered == 1)
|
||||
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
|
||||
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
|
||||
@@ -216,6 +256,7 @@ class NativeInputCapture {
|
||||
}
|
||||
SinkAbi abi_;
|
||||
bool enabled_;
|
||||
bool closed_ = false;
|
||||
std::mutex mutex_;
|
||||
std::map<void*, std::unique_ptr<Session>> sessions_;
|
||||
};
|
||||
|
||||
@@ -130,14 +130,6 @@ std::uintptr_t FunctionAddress(Function function) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu
|
||||
// profile: relocating query methods alone does not establish a recording ABI.
|
||||
namespace {
|
||||
constexpr std::array<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
|
||||
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
|
||||
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
|
||||
} // namespace
|
||||
|
||||
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
|
||||
|
||||
namespace internal {
|
||||
@@ -226,10 +218,6 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
|
||||
"another Roblox output-device bridge owns the process");
|
||||
}
|
||||
profile_ = *profile.fmod_output_device_bridge;
|
||||
capture_profile_supported_ =
|
||||
profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
|
||||
profile_.vtable_rva == 0x6cd3ce0 &&
|
||||
profile_.vtable_layout_version == 2 && profile_.has_input_devices();
|
||||
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
|
||||
g_active_bridge.store(nullptr, std::memory_order_release);
|
||||
profile_ = {};
|
||||
@@ -255,7 +243,10 @@ void RobloxOutputDeviceBridge::Shutdown() {
|
||||
" [audio-menu] failed to restore FmodAudioDevice "
|
||||
"vtable protection\n");
|
||||
}
|
||||
g_native_capture.store(nullptr, std::memory_order_release);
|
||||
std::atomic_store_explicit(&g_native_capture,
|
||||
std::shared_ptr<NativeInputCapture>{},
|
||||
std::memory_order_release);
|
||||
if (capture_) capture_->Shutdown();
|
||||
capture_.reset();
|
||||
active_ = false;
|
||||
devices_.clear();
|
||||
@@ -419,6 +410,7 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
|
||||
Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
|
||||
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
|
||||
const auto capture_indexes = profile_.capture_vtable_indexes();
|
||||
if (profile_.vtable_rva >
|
||||
std::numeric_limits<std::uintptr_t>::max() -
|
||||
(kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) {
|
||||
@@ -461,26 +453,25 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
"FmodAudioDevice string ABI contract validation failed");
|
||||
}
|
||||
|
||||
if (capture_profile_supported_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) {
|
||||
if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) ||
|
||||
vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i])
|
||||
if (profile_.has_input_devices()) {
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i) {
|
||||
const auto method = vtable_[capture_indexes[i]];
|
||||
if (method < library_base_ ||
|
||||
!IsExecutableImageRange(library_base_, method - library_base_, 1) ||
|
||||
std::find(profile_.input_method_rvas.begin(),
|
||||
profile_.input_method_rvas.end(), method - library_base_) !=
|
||||
profile_.input_method_rvas.end() ||
|
||||
std::find(original_capture_methods_.begin(),
|
||||
original_capture_methods_.begin() + i, method) !=
|
||||
original_capture_methods_.begin() + i)
|
||||
return FailedPrecondition("native input capture vtable ABI mismatch");
|
||||
original_capture_methods_[i] = vtable_[kCaptureSlots[i]];
|
||||
original_capture_methods_[i] = method;
|
||||
}
|
||||
if (!IsExecutableImageRange(library_base_, 0x3213154, 1) ||
|
||||
!IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) ||
|
||||
!IsRelroImageRange(library_base_, 0x6cd4550, 24))
|
||||
return FailedPrecondition("native input capture sink ABI outside image");
|
||||
capture_ = std::make_unique<NativeInputCapture>(
|
||||
capture_ = std::make_shared<NativeInputCapture>(
|
||||
NativeInputCapture::SinkAbi{
|
||||
library_base_ + 0x6cd4550,
|
||||
reinterpret_cast<NativeInputCapture::DeliverPcm>(
|
||||
library_base_ + 0x3213154),
|
||||
reinterpret_cast<NativeInputCapture::Destroy>(
|
||||
library_base_ + 0x1dc4cd0),
|
||||
library_base_, &IsRelroImageRange, &IsExecutableImageRange,
|
||||
reinterpret_cast<NativeInputCapture::OriginalLatency>(
|
||||
library_base_ + kCaptureRvas[1])},
|
||||
original_capture_methods_[1])},
|
||||
!input_devices_.empty());
|
||||
}
|
||||
|
||||
@@ -519,7 +510,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
g_native_capture.store(capture_.get(), std::memory_order_release);
|
||||
std::atomic_store_explicit(&g_native_capture, capture_,
|
||||
std::memory_order_release);
|
||||
const std::array<std::uintptr_t, 6> methods{
|
||||
FunctionAddress(&NativeInputCapture::Format),
|
||||
FunctionAddress(&NativeInputCapture::Latency),
|
||||
@@ -527,8 +519,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
FunctionAddress(&NativeInputCapture::Stop),
|
||||
FunctionAddress(&NativeInputCapture::Poll),
|
||||
FunctionAddress(&NativeInputCapture::Recording)};
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE);
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[capture_indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||
}
|
||||
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
|
||||
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
|
||||
@@ -546,10 +538,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
|
||||
__ATOMIC_RELEASE);
|
||||
g_native_capture.store(nullptr, std::memory_order_release);
|
||||
std::atomic_store_explicit(&g_native_capture,
|
||||
std::shared_ptr<NativeInputCapture>{},
|
||||
std::memory_order_release);
|
||||
capture_->Shutdown();
|
||||
capture_.reset();
|
||||
}
|
||||
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||
@@ -581,8 +576,9 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
||||
const auto capture_indexes = profile_.capture_vtable_indexes();
|
||||
for (std::size_t i = 0; i < capture_indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i],
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||
|
||||
@@ -150,6 +150,15 @@ Result ParseInHostCLocale(Parser parser) noexcept {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Highest errno in Bionic's message table (EHWPOISON). Numbers 41 and 58 are
|
||||
// unassigned in the generic Linux errno list, so Bionic has no text for them.
|
||||
constexpr int kBionicMaxErrno = 133;
|
||||
|
||||
constexpr bool HasBionicErrnoMessage(int error_number) noexcept {
|
||||
return error_number >= 0 && error_number <= kBionicMaxErrno &&
|
||||
error_number != 41 && error_number != 58;
|
||||
}
|
||||
|
||||
template <typename Result>
|
||||
const char* HostStrErrorResult(Result result, char* buffer) noexcept {
|
||||
if constexpr (std::is_integral_v<Result>) {
|
||||
@@ -318,10 +327,16 @@ int BionicStrError(int error_number, char* buffer,
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Bionic's table ends at EHWPOISON. Unknown numbers get the same text on
|
||||
// every host instead of whatever the host libc prints (musl has no
|
||||
// "Unknown error N" form).
|
||||
std::array<char, 256> host_buffer{};
|
||||
auto host_result =
|
||||
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
|
||||
const char* message = HostStrErrorResult(host_result, host_buffer.data());
|
||||
const char* message = nullptr;
|
||||
if (HasBionicErrnoMessage(error_number)) {
|
||||
auto host_result =
|
||||
::strerror_r(error_number, host_buffer.data(), host_buffer.size());
|
||||
message = HostStrErrorResult(host_result, host_buffer.data());
|
||||
}
|
||||
|
||||
std::array<char, 64> unknown_buffer{};
|
||||
if (message == nullptr) {
|
||||
@@ -373,6 +388,10 @@ extern "C" void mocktail_bionic_arc4random_buf(void* buffer, size_t size) {
|
||||
mocktail::compat::BionicArc4RandomBuffer(buffer, size);
|
||||
}
|
||||
|
||||
extern "C" int mocktail_bionic_atoi(const char* text) {
|
||||
return static_cast<int>(std::strtol(text, nullptr, 10));
|
||||
}
|
||||
|
||||
extern "C" mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo() {
|
||||
return mocktail::compat::BionicMallinfo();
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
|
||||
if (RejectRealtimeScheduling(source.sched_policy)) {
|
||||
return EPERM;
|
||||
}
|
||||
if (source.sched_policy != SCHED_NORMAL) {
|
||||
if (source.sched_policy != SCHED_OTHER) {
|
||||
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
|
||||
@@ -148,6 +148,57 @@ bool ParseOptionalFmodOutputDeviceBridge(
|
||||
return input_fields_present == 0 || input_fields_present == 4;
|
||||
}
|
||||
|
||||
bool ParseOptionalVrDebugDeviceBridge(
|
||||
const Json& object,
|
||||
std::optional<VrDebugDeviceBridgeProfile>* bridge_profile) {
|
||||
if (bridge_profile == nullptr) {
|
||||
return false;
|
||||
}
|
||||
bridge_profile->reset();
|
||||
const auto field = object.find("vr_debug_device_bridge");
|
||||
if (field == object.end() || field->is_null()) {
|
||||
return true;
|
||||
}
|
||||
if (!field->is_object()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<std::uintptr_t> vtable_rva;
|
||||
std::optional<std::uintptr_t> constructor_rva;
|
||||
std::optional<std::uintptr_t> state_getter_rva;
|
||||
std::optional<std::uintptr_t> eye_getter_rva;
|
||||
std::optional<std::uintptr_t> eye_initializer_rva;
|
||||
std::optional<std::uintptr_t> emulator_flag_storage_rva;
|
||||
std::optional<std::uintptr_t> device_create_framebuffer_vtable_offset;
|
||||
if (!ParseOptionalRva(*field, "vtable_rva", &vtable_rva) ||
|
||||
!ParseOptionalRva(*field, "constructor_rva", &constructor_rva) ||
|
||||
!ParseOptionalRva(*field, "state_getter_rva", &state_getter_rva) ||
|
||||
!ParseOptionalRva(*field, "eye_getter_rva", &eye_getter_rva) ||
|
||||
!ParseOptionalRva(*field, "eye_initializer_rva", &eye_initializer_rva) ||
|
||||
!ParseOptionalRva(*field, "emulator_flag_storage_rva",
|
||||
&emulator_flag_storage_rva) ||
|
||||
!ParseOptionalRva(*field, "device_create_framebuffer_vtable_offset",
|
||||
&device_create_framebuffer_vtable_offset) ||
|
||||
!vtable_rva.has_value() || !constructor_rva.has_value() ||
|
||||
!state_getter_rva.has_value() || !eye_getter_rva.has_value() ||
|
||||
!eye_initializer_rva.has_value() ||
|
||||
!emulator_flag_storage_rva.has_value() ||
|
||||
!device_create_framebuffer_vtable_offset.has_value()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*bridge_profile = VrDebugDeviceBridgeProfile{
|
||||
*vtable_rva,
|
||||
*constructor_rva,
|
||||
*state_getter_rva,
|
||||
*eye_getter_rva,
|
||||
*eye_initializer_rva,
|
||||
*emulator_flag_storage_rva,
|
||||
*device_create_framebuffer_vtable_offset,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string_view BuildStatusName(BuildStatus status) noexcept {
|
||||
@@ -211,6 +262,7 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
|
||||
const Json* reason = RequiredField(entry, "reason");
|
||||
std::optional<std::uintptr_t> fullscreen_setter_rva;
|
||||
std::optional<FmodOutputDeviceBridgeProfile> fmod_output_device_bridge;
|
||||
std::optional<VrDebugDeviceBridgeProfile> vr_debug_device_bridge;
|
||||
if (version_name == nullptr || !version_name->is_string() ||
|
||||
version_code == nullptr || !version_code->is_number_integer() ||
|
||||
profile_build_id == nullptr || !profile_build_id->is_string() ||
|
||||
@@ -227,7 +279,8 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
|
||||
"user_game_settings_fullscreen_setter_rva",
|
||||
&fullscreen_setter_rva) ||
|
||||
!ParseOptionalFmodOutputDeviceBridge(
|
||||
entry, &fmod_output_device_bridge)) {
|
||||
entry, &fmod_output_device_bridge) ||
|
||||
!ParseOptionalVrDebugDeviceBridge(entry, &vr_debug_device_bridge)) {
|
||||
return Failure("compatibility profile has missing or invalid fields");
|
||||
}
|
||||
|
||||
@@ -278,6 +331,7 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
|
||||
allow_host_constructor_replay->get<bool>(),
|
||||
fullscreen_setter_rva,
|
||||
fmod_output_device_bridge,
|
||||
vr_debug_device_bridge,
|
||||
reason->get_ref<const std::string&>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,27 @@ namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
constexpr HostAbiProfile kProfiles[] = {
|
||||
// Roblox 2.738.1397; promoted from the exact-build probation profile.
|
||||
{"5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
|
||||
{{{0x1d73422, HostBridgeKind::kAllocate, "small-allocate"},
|
||||
{0x1d7667a, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x222f4c0, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1d731c2, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x1d8e7f9, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"},
|
||||
{0x1d76c19, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0x76e2da0, 0x77efb98, 0x7c4b780, 0x400, 0x1d73838, 0x1dffad7, 0x7b87830,
|
||||
0x7b878c0, 0x400000},
|
||||
{0x1d731c2, 0x1d76c19},
|
||||
0x70e1618,
|
||||
3599,
|
||||
{{{2, 3}, {5, 3599}}},
|
||||
2,
|
||||
{{{2, 3599}}},
|
||||
1,
|
||||
2,
|
||||
{0x22308d3, 0x7b8b998},
|
||||
HostAllocatorStrategy::kNativeMimalloc},
|
||||
// Roblox 2.736.1408 arm64-v8a. Every RVA was derived from the AArch64
|
||||
// payload of the same release as the x86-64 reference above, matched by
|
||||
// allocator internals, init_array call graphs, and static state layout.
|
||||
@@ -41,12 +62,11 @@ constexpr HostAbiProfile kProfiles[] = {
|
||||
{0x1d2f43a, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x21e68bf, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1d2bf82, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x1d47a63, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1d47a63, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"},
|
||||
{0x1d2f9d9, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0x7652e70, 0x775fd28, 0x7bb4340, 0x400, 0x1d2c5f8, 0x1db7b57,
|
||||
0x7af0430, 0x7af04c0, 0x400000},
|
||||
{0x7652e70, 0x775fd28, 0x7bb4340, 0x400, 0x1d2c5f8, 0x1db7b57, 0x7af0430,
|
||||
0x7af04c0, 0x400000},
|
||||
{0x1d2bf82, 0x1d2f9d9},
|
||||
0x7057818,
|
||||
3570,
|
||||
@@ -66,12 +86,11 @@ constexpr HostAbiProfile kProfiles[] = {
|
||||
{0x1cf9c3a, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x21af33f, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1cf677e, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x1d12197, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1d12197, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"},
|
||||
{0x1cfa1d9, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0x7571570, 0x767e428, 0x7ad47e8, 0x400, 0x1cf6df8, 0x1d82be7,
|
||||
0x7a10730, 0x7a107c0, 0x400000},
|
||||
{0x7571570, 0x767e428, 0x7ad47e8, 0x400, 0x1cf6df8, 0x1d82be7, 0x7a10730,
|
||||
0x7a107c0, 0x400000},
|
||||
{0x1cf677e, 0x1cfa1d9},
|
||||
0x6f78a18,
|
||||
3556,
|
||||
@@ -92,12 +111,11 @@ constexpr HostAbiProfile kProfiles[] = {
|
||||
{0x1fe4c84, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x203daae, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1c39c3a, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x1c8dbf8, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1c8dbf8, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"},
|
||||
{0x1c3eb72, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0x7030ae0, 0x703a7e8, 0x72a16d0, 0x400, 0x1c39f1d, 0x2040817,
|
||||
0x71e92f0, 0x71e9380, 0x400000},
|
||||
{0x7030ae0, 0x703a7e8, 0x72a16d0, 0x400, 0x1c39f1d, 0x2040817, 0x71e92f0,
|
||||
0x71e9380, 0x400000},
|
||||
{0x1c39c3a, 0x1c3eb72},
|
||||
0x6c2c9b0,
|
||||
0x6cc8 / 8,
|
||||
@@ -270,6 +288,42 @@ bool HostAbiProfile::ShouldInitializeNativeMimallocThreadAfterConstructor(
|
||||
index == native_mimalloc_thread_initializer_after_constructor;
|
||||
}
|
||||
|
||||
bool HostAbiProfile::HasVerifiedConstructorPolicy(
|
||||
size_t jni_singleton_constructor) const noexcept {
|
||||
constexpr size_t kLeadingConstructors = 4;
|
||||
constexpr size_t kWiringConstructors = 2;
|
||||
if (!HasValidConstructorRanges() ||
|
||||
!HasValidNativeMimallocConstructorRanges() ||
|
||||
native_mimalloc_constructor_run_range_count != 1) {
|
||||
return false;
|
||||
}
|
||||
const size_t begin = native_mimalloc_constructor_run_ranges[0].begin;
|
||||
const size_t checkpoint =
|
||||
native_mimalloc_thread_initializer_after_constructor;
|
||||
const ConstructorRange &tail =
|
||||
constructor_run_ranges[constructor_run_range_count - 1];
|
||||
if (init_array_count < begin + kLeadingConstructors ||
|
||||
native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
||||
init_array_count ||
|
||||
checkpoint < begin || checkpoint >= begin + kLeadingConstructors ||
|
||||
!AllowsConstructor(checkpoint) || jni_singleton_constructor < begin ||
|
||||
jni_singleton_constructor >= begin + kLeadingConstructors ||
|
||||
jni_singleton_constructor == checkpoint ||
|
||||
!AllowsConstructor(jni_singleton_constructor) ||
|
||||
ConstructorRangeBegin() < begin ||
|
||||
tail.begin > begin + kLeadingConstructors ||
|
||||
tail.end_exclusive != init_array_count) {
|
||||
return false;
|
||||
}
|
||||
size_t excluded = 0;
|
||||
for (size_t index = begin; index < begin + kLeadingConstructors; ++index) {
|
||||
if (!AllowsConstructor(index)) {
|
||||
++excluded;
|
||||
}
|
||||
}
|
||||
return excluded == kWiringConstructors;
|
||||
}
|
||||
|
||||
bool HostAbiProfile::HasValidNativePreJniBootstrap() const noexcept {
|
||||
return native_pre_jni_bootstrap.IsValid();
|
||||
}
|
||||
|
||||
@@ -612,26 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
|
||||
*error = "external host ABI profile has inconsistent constructor policy";
|
||||
return nullptr;
|
||||
}
|
||||
// The verified constructor layout is the same on both guest ABIs: a single
|
||||
// process-load constructor at the thread-initializer checkpoint, two wiring
|
||||
// entries excluded under host bridges, and one contiguous native-mimalloc
|
||||
// replay from the checkpoint to the end of .init_array. Only the checkpoint
|
||||
// index itself differs between payloads.
|
||||
const std::size_t checkpoint =
|
||||
owned->profile.native_mimalloc_thread_initializer_after_constructor;
|
||||
if (owned->profile.init_array_count <= checkpoint + 3 ||
|
||||
owned->profile.constructor_run_range_count != 2 ||
|
||||
owned->profile.constructor_run_ranges[0].begin != checkpoint ||
|
||||
owned->profile.constructor_run_ranges[0].end_exclusive != checkpoint + 1 ||
|
||||
owned->profile.constructor_run_ranges[1].begin != checkpoint + 3 ||
|
||||
owned->profile.constructor_run_ranges[1].end_exclusive !=
|
||||
owned->profile.init_array_count ||
|
||||
owned->profile.native_mimalloc_constructor_run_range_count != 1 ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin !=
|
||||
checkpoint ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
||||
owned->profile.init_array_count ||
|
||||
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
|
||||
// The verified constructor layout is the same on both guest ABIs. Payloads
|
||||
// differ in the order of the four leading constructors, and the guest ABIs
|
||||
// differ in the index the native replay starts at.
|
||||
// derivation_code_rvas holds three initializer anchors, then the anchors of
|
||||
// the four leading constructors in .init_array order.
|
||||
constexpr size_t kJniSingletonAnchor = 2;
|
||||
constexpr size_t kFirstConstructorAnchor = 3;
|
||||
constexpr size_t kConstructorAnchors = 4;
|
||||
size_t jni_singleton_constructor = owned->profile.init_array_count;
|
||||
for (size_t anchor = 0; anchor < kConstructorAnchors; ++anchor) {
|
||||
if (owned->derivation_code_rvas[kFirstConstructorAnchor + anchor] ==
|
||||
owned->derivation_code_rvas[kJniSingletonAnchor]) {
|
||||
jni_singleton_constructor =
|
||||
owned->profile.NativeMimallocConstructorRangeBegin() + anchor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!owned->profile.HasVerifiedConstructorPolicy(jni_singleton_constructor) ||
|
||||
(kGuestElfMachine == EM_X86_64 &&
|
||||
owned->profile.NativeMimallocConstructorRangeBegin() != 2)) {
|
||||
*error =
|
||||
"external host ABI profile changed the verified constructor policy";
|
||||
return nullptr;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -37,9 +37,14 @@ PresentModePolicy ResolvePresentModePolicy(std::string_view vsync_value,
|
||||
|
||||
PresentModePolicy CachedPresentModePolicy() {
|
||||
static const PresentModePolicy policy = [] {
|
||||
// main() clears MOCKTAIL_VR_ENABLED when VR cannot start for this build,
|
||||
// so the VR limit only applies while VR is actually active.
|
||||
const std::string_view vr = EnvironmentOr("MOCKTAIL_VR_ENABLED", "0");
|
||||
const bool vr_enabled = vr == "1" || vr == "true" || vr == "on";
|
||||
return ResolvePresentModePolicy(
|
||||
EnvironmentOr("MOCKTAIL_VSYNC", "auto"),
|
||||
EnvironmentOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1"));
|
||||
vr_enabled ? EnvironmentOr("MOCKTAIL_VR_FRAME_RATE_LIMIT", "unlimited")
|
||||
: EnvironmentOr("MOCKTAIL_FRAME_RATE_LIMIT", "-1"));
|
||||
}();
|
||||
return policy;
|
||||
}
|
||||
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user