mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19bd8406f | ||
|
|
cd46538eb8 | ||
|
|
eca5f395eb | ||
|
|
26767e925c | ||
|
|
01a0e40463 | ||
|
|
a11e833e06 | ||
|
|
b9bce36824 | ||
|
|
14c8a55211 | ||
|
|
5c5422fa02 | ||
|
|
9b041ec2ac | ||
|
|
5f83c4a64f | ||
|
|
a9d8e3185e | ||
|
|
67a9f4db97 | ||
|
|
f07daccc82 | ||
|
|
2eef4f966f | ||
|
|
41003efb09 | ||
|
|
29a0f03a90 | ||
|
|
5a27aa0366 | ||
|
|
c99a55dc54 | ||
|
|
30d50f15d1 | ||
|
|
ab940acfb5 | ||
|
|
719b1fdd22 | ||
|
|
1fa2e116b3 | ||
|
|
35359f3575 | ||
|
|
40237fae72 | ||
|
|
ad6a3c3147 | ||
|
|
d982cd9506 | ||
|
|
65adb3bddc | ||
|
|
47e23df2e0 | ||
|
|
c0ff1842ee | ||
|
|
e073947b91 | ||
|
|
66de662e5b | ||
|
|
6c3631058c | ||
|
|
6829fd00ed | ||
|
|
8be3618cd5 | ||
|
|
265b476440 | ||
|
|
ff905fe5b7 | ||
|
|
02108a9bb1 | ||
|
|
7dd32335ca | ||
|
|
5ed6ed5e84 | ||
|
|
cef1b3ec4a | ||
|
|
854969457f |
No files matched your search
@@ -11,14 +11,14 @@ body:
|
||||
value: |
|
||||
Please include enough detail for someone else to reproduce the bug.
|
||||
|
||||
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
|
||||
Use any language you are comfortable with.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
Please write issues and pull requests yourself.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Mocktail version
|
||||
label: Nightcap version
|
||||
description: Provide the release version, package version, or commit hash.
|
||||
placeholder: "For example: v0.4.0 or 1a2b3c4"
|
||||
validations:
|
||||
@@ -37,7 +37,7 @@ body:
|
||||
id: installation
|
||||
attributes:
|
||||
label: Installation method
|
||||
description: Tell us how Mocktail was installed or built.
|
||||
description: Tell us how Nightcap was installed or built.
|
||||
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
|
||||
validations:
|
||||
required: true
|
||||
@@ -56,7 +56,7 @@ body:
|
||||
label: Steps to reproduce
|
||||
description: List the smallest reliable sequence of actions that triggers the problem.
|
||||
placeholder: |
|
||||
1. Start Mocktail with ...
|
||||
1. Start Nightcap with ...
|
||||
2. Open ...
|
||||
3. Select ...
|
||||
4. Observe ...
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
name: Feature request
|
||||
description: Suggest an improvement or a new capability for Mocktail
|
||||
description: Suggest an improvement or a new capability for Nightcap
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
@@ -10,9 +11,9 @@ body:
|
||||
value: |
|
||||
Describe what you want to change and why it would be useful.
|
||||
|
||||
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
|
||||
Use any language you are comfortable with.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
Please write issues and pull requests yourself.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -26,7 +27,7 @@ body:
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe how you would like Mocktail to behave.
|
||||
description: Describe how you would like Nightcap to behave.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
|
||||
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
|
||||
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
|
||||
|
||||
Closes #
|
||||
+16
-74
@@ -1,8 +1,6 @@
|
||||
name: Publish AUR packages
|
||||
name: Check AUR packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -13,8 +11,8 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish AUR packages
|
||||
metadata:
|
||||
name: Refresh AUR metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -27,10 +25,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
package_dir="$GITHUB_WORKSPACE/packaging/aur/mocktail-git"
|
||||
version="$(sed -n \
|
||||
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
|
||||
CMakeLists.txt | head -n 1)"
|
||||
package_dir="$GITHUB_WORKSPACE/packaging/aur/nightcap-git"
|
||||
version="$(git describe --tags --abbrev=0 --match='v[0-9]*')"
|
||||
version="${version#v}"
|
||||
[[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]
|
||||
pkgver="${version}.r$(git rev-list --count HEAD).g$(git rev-parse --short=7 HEAD)"
|
||||
sed -i "s/^pkgver=.*/pkgver=${pkgver}/" "$package_dir/PKGBUILD"
|
||||
@@ -39,76 +36,21 @@ jobs:
|
||||
--volume "$package_dir:/pkg" \
|
||||
archlinux:base-devel bash -Eeuo pipefail -c '
|
||||
useradd --create-home builder
|
||||
install -d -o builder -g builder /tmp/mocktail-git
|
||||
install -d -o builder -g builder /tmp/nightcap-git
|
||||
install -o builder -g builder -m 0644 \
|
||||
/pkg/PKGBUILD /tmp/mocktail-git/PKGBUILD
|
||||
/pkg/PKGBUILD /tmp/nightcap-git/PKGBUILD
|
||||
runuser -u builder -- bash -c \
|
||||
"cd /tmp/mocktail-git && makepkg --printsrcinfo" \
|
||||
"cd /tmp/nightcap-git && makepkg --printsrcinfo" \
|
||||
> /pkg/.SRCINFO
|
||||
'
|
||||
|
||||
grep -Fqx "pkgver=${pkgver}" "$package_dir/PKGBUILD"
|
||||
grep -Fqx $'\tpkgver = '"${pkgver}" "$package_dir/.SRCINFO"
|
||||
printf 'AUR_PKGVER=%s\n' "$pkgver" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish to the AUR
|
||||
shell: bash
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
AUR_SSH_KEY_PASSPHRASE: ${{ secrets.AUR_SSH_KEY_PASSPHRASE }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ -n "$AUR_SSH_PRIVATE_KEY" ]]
|
||||
[[ -n "$AUR_SSH_KEY_PASSPHRASE" ]]
|
||||
|
||||
private_key="$RUNNER_TEMP/aur_ed25519"
|
||||
askpass="$RUNNER_TEMP/aur_askpass"
|
||||
known_hosts="$RUNNER_TEMP/aur_known_hosts"
|
||||
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$private_key"
|
||||
unset AUR_SSH_PRIVATE_KEY
|
||||
chmod 0600 "$private_key"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf '\''%s\n'\'' "$AUR_SSH_KEY_PASSPHRASE"' \
|
||||
> "$askpass"
|
||||
chmod 0700 "$askpass"
|
||||
eval "$(ssh-agent -s)"
|
||||
trap 'ssh-agent -k >/dev/null; rm -f -- "$private_key" "$askpass"' EXIT
|
||||
DISPLAY=:0 SSH_ASKPASS="$askpass" SSH_ASKPASS_REQUIRE=force \
|
||||
ssh-add "$private_key" </dev/null
|
||||
unset AUR_SSH_KEY_PASSPHRASE
|
||||
|
||||
ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "$known_hosts"
|
||||
fingerprint="$(ssh-keygen -lf "$known_hosts" -E sha256 | awk '{print $2}')"
|
||||
[[ "$fingerprint" == \
|
||||
'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4' ]]
|
||||
|
||||
export GIT_SSH_COMMAND="ssh -F /dev/null -i $private_key \
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile=$known_hosts"
|
||||
publish_aur_package() {
|
||||
local package_name="$1"
|
||||
local commit_message="$2"
|
||||
local source_dir="packaging/aur/$package_name"
|
||||
local aur_dir="$RUNNER_TEMP/${package_name}-aur"
|
||||
|
||||
git clone --branch master --single-branch \
|
||||
"ssh://aur@aur.archlinux.org/${package_name}.git" "$aur_dir"
|
||||
install -m 0644 "$source_dir/PKGBUILD" "$aur_dir/PKGBUILD"
|
||||
install -m 0644 "$source_dir/.SRCINFO" "$aur_dir/.SRCINFO"
|
||||
|
||||
git -C "$aur_dir" config user.name komaruworld
|
||||
git -C "$aur_dir" config user.email rtysus228@gmail.com
|
||||
git -C "$aur_dir" add PKGBUILD .SRCINFO
|
||||
if git -C "$aur_dir" diff --cached --quiet; then
|
||||
printf '%s is already published\n' "$package_name"
|
||||
return
|
||||
fi
|
||||
git -C "$aur_dir" commit -m "$commit_message"
|
||||
git -C "$aur_dir" push origin HEAD:master
|
||||
}
|
||||
|
||||
publish_aur_package mocktail-git "Update to ${AUR_PKGVER}"
|
||||
publish_aur_package mocktail 'make builds stable'
|
||||
publish_aur_package mocktail-bin 'make builds stable'
|
||||
- name: Upload AUR metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-AUR
|
||||
path: packaging/aur/nightcap-git/.SRCINFO
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -8,22 +8,32 @@ on:
|
||||
- 'CMakeLists.txt'
|
||||
- 'Makefile'
|
||||
- 'README.md'
|
||||
- '.gitmodules'
|
||||
- 'cmake/**'
|
||||
- 'config/**'
|
||||
- 'include/**'
|
||||
- 'packaging/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
- 'stubs/**'
|
||||
- 'tests/**'
|
||||
- 'third_party/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'CMakeLists.txt'
|
||||
- 'Makefile'
|
||||
- 'README.md'
|
||||
- '.gitmodules'
|
||||
- 'cmake/**'
|
||||
- 'config/**'
|
||||
- 'include/**'
|
||||
- 'packaging/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
- 'stubs/**'
|
||||
- 'tests/**'
|
||||
- 'third_party/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -65,3 +75,43 @@ jobs:
|
||||
bash tests/flatpak_packaging_test.sh .
|
||||
bash tests/native_packaging_test.sh .
|
||||
bash tests/anylinux_dependencies_test.sh .
|
||||
|
||||
native-build:
|
||||
name: Native build (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
runner: ubuntu-26.04
|
||||
- arch: aarch64
|
||||
runner: ubuntu-26.04-arm
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes --no-install-recommends \
|
||||
build-essential binutils cmake git ninja-build pkg-config lld \
|
||||
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
|
||||
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
||||
libplacebo-dev libpng-dev zlib1g-dev
|
||||
|
||||
- name: Build and check native executables
|
||||
shell: bash
|
||||
run: |
|
||||
test "$(uname -m)" = "${{ matrix.arch }}"
|
||||
cmake -S . -B build-ci -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
|
||||
cmake --build build-ci --parallel 4
|
||||
./build-ci/mocktail --help
|
||||
./build-ci/mocktail_updater status
|
||||
@@ -126,10 +126,153 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
deb:
|
||||
name: DEB (Ubuntu 26.04)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Build DEB
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
|
||||
ubuntu:26.04 bash -Eeuo pipefail -c '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install --yes --no-install-recommends \
|
||||
build-essential cmake git ninja-build pkg-config lld fakeroot \
|
||||
dpkg-dev file libsdl3-dev libsdl3-ttf-dev \
|
||||
libcurl4-openssl-dev libssl-dev nlohmann-json3-dev \
|
||||
libyaml-dev libelf-dev libminizip-dev libcapstone-dev \
|
||||
libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||
libutf8proc-dev libfontconfig1-dev libegl-dev \
|
||||
libvulkan-dev libplacebo-dev zlib1g-dev libpng-dev
|
||||
cmake -S . -B build-deb -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DMOCKTAIL_PACKAGE_NAME=nightcap \
|
||||
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build-deb --parallel 4
|
||||
cpack --config build-deb/CPackConfig.cmake \
|
||||
-G DEB -B dist/deb
|
||||
dpkg-deb --info dist/deb/*.deb >/dev/null
|
||||
'
|
||||
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-DEB
|
||||
path: dist/deb/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
rpm:
|
||||
name: RPM (Fedora 44)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Build RPM
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
|
||||
fedora:44 bash -Eeuo pipefail -c '
|
||||
dnf install --assumeyes \
|
||||
gcc-c++ cmake git ninja-build pkgconf-pkg-config lld rpm-build \
|
||||
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
|
||||
nlohmann-json-devel libyaml-devel elfutils-libelf-devel \
|
||||
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
|
||||
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
|
||||
libglvnd-devel vulkan-headers vulkan-loader-devel \
|
||||
libplacebo-devel zlib-ng-compat-devel libpng-devel
|
||||
cmake -S . -B build-rpm -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DMOCKTAIL_PACKAGE_NAME=nightcap \
|
||||
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build-rpm --parallel 4
|
||||
cpack --config build-rpm/CPackConfig.cmake \
|
||||
-G RPM -B dist/rpm
|
||||
rpm --query --package --info dist/rpm/*.rpm >/dev/null
|
||||
'
|
||||
|
||||
- name: Upload RPM
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-RPM
|
||||
path: dist/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
arch:
|
||||
name: Pacman package (Arch Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Build pacman package
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
version="$(./scripts/package_version.sh)"
|
||||
sed -i "s/^pkgver=.*/pkgver=${version}/" packaging/arch/PKGBUILD
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||
pacman -Syu --needed --noconfirm \
|
||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||
nlohmann-json libyaml libelf minizip capstone gtk4 \
|
||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libpng \
|
||||
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
|
||||
useradd --create-home builder
|
||||
chown -R builder:builder /workspace
|
||||
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
|
||||
makepkg --dir /workspace/packaging/arch \
|
||||
--noconfirm --cleanbuild --force
|
||||
install -d dist/arch
|
||||
cp packaging/arch/*.pkg.tar.zst dist/arch/
|
||||
pacman -Qp dist/arch/*.pkg.tar.zst >/dev/null
|
||||
'
|
||||
|
||||
- name: Upload pacman package
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Nightcap-Arch
|
||||
path: dist/arch/*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [appimage]
|
||||
needs: [appimage, deb, rpm, arch]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -138,6 +281,13 @@ jobs:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: 'Nightcap-*'
|
||||
merge-multiple: true
|
||||
path: release
|
||||
|
||||
- name: Download AppImage
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -152,7 +302,28 @@ jobs:
|
||||
set -Eeuo pipefail
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
|
||||
(cd release && sha256sum Nightcap-x86_64.AppImage > Nightcap-x86_64.AppImage.sha256)
|
||||
# An unmatched glob would otherwise reach sha256sum and gh as its own
|
||||
# literal text, so every package pattern has to match before upload.
|
||||
shopt -s nullglob
|
||||
assets=(Nightcap-x86_64.AppImage)
|
||||
for glob in '*.deb' '*.rpm' '*.pkg.tar.zst'; do
|
||||
matched=(release/$glob)
|
||||
if [[ ${#matched[@]} -eq 0 ]]; then
|
||||
echo "::error::no ${glob} package among the release artifacts"
|
||||
exit 1
|
||||
fi
|
||||
assets+=("${matched[@]#release/}")
|
||||
done
|
||||
uploads=()
|
||||
for asset in "${assets[@]}"; do
|
||||
uploads+=("release/$asset" "release/$asset.sha256")
|
||||
done
|
||||
(
|
||||
cd release
|
||||
for asset in "${assets[@]}"; do
|
||||
sha256sum "$asset" > "$asset.sha256"
|
||||
done
|
||||
)
|
||||
./scripts/release_notes.sh "$tag" > release/notes.md
|
||||
# The Flatpak workflow creates the same release; whichever job
|
||||
# arrives second sees "already exists" and continues.
|
||||
@@ -164,5 +335,4 @@ jobs:
|
||||
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--title "Nightcap $tag" --notes-file release/notes.md
|
||||
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
|
||||
release/Nightcap-x86_64.AppImage \
|
||||
release/Nightcap-x86_64.AppImage.sha256
|
||||
"${uploads[@]}"
|
||||
@@ -177,3 +177,6 @@ lsan.log*
|
||||
# Test fixtures
|
||||
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.zip
|
||||
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.apk
|
||||
|
||||
# Working plans, not part of the shipped documentation
|
||||
/docs/plans/
|
||||
+106
-1
@@ -6,6 +6,109 @@ GitHub are taken from the matching section here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.2] - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Performance recordings made with `--profile` now show how busy the CPU was
|
||||
during each frame, which makes it easier to tell what caused a stutter.
|
||||
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android
|
||||
`arm64-v8a` client.
|
||||
|
||||
### Fixed
|
||||
|
||||
- When Roblox asks for a Google Play device check during login, Nightcap now
|
||||
answers right away that it isn't supported. Before, the "Verifying you're
|
||||
not a bot" screen waited about a minute for an answer and then sent you
|
||||
back to the login screen.
|
||||
- Solving a login challenge now closes its window. Before, Roblox's
|
||||
"challenge completed" signal was ignored and the window stayed open.
|
||||
Thanks to @glook9001.
|
||||
- Login no longer stalls after you pass a verification check. Before, Roblox
|
||||
only saw the page's "challenge completed" signal after a one-minute
|
||||
timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
||||
can't handle this way, the log says "WebViewProtocol getter not
|
||||
recognized" and web pages behave as before.
|
||||
- Login check windows and other web pages get 5 seconds to start instead of
|
||||
3, so a slow start no longer closes them. The log says how long each one
|
||||
took to open.
|
||||
- Less stutter while joining a game and while textures load in. In Blade
|
||||
Ball, loading hitches dropped by about half, and the worst one went from
|
||||
0.8 seconds to 0.55 seconds.
|
||||
- Clicking Play on a private server now joins right away. Before, nothing
|
||||
happened until you clicked a sidebar tab like Home.
|
||||
- Middle click now reaches Roblox as the middle mouse button.
|
||||
- Scrolling follows your system's natural scrolling setting.
|
||||
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other
|
||||
than the one making the call.
|
||||
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles
|
||||
for the same version.
|
||||
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or
|
||||
`MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID` is now ignored instead of being
|
||||
partly read.
|
||||
|
||||
### Changed
|
||||
|
||||
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by
|
||||
default instead of 4, so a local Flatpak build is less likely to run out
|
||||
of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use more.
|
||||
Thanks to @glook9001.
|
||||
|
||||
## [0.3.1] - 2026-09-20
|
||||
|
||||
### Added
|
||||
|
||||
- Each release carries a DEB, an RPM, and a pacman package beside the
|
||||
AppImage, with a `.sha256` for every file. They install `/usr/bin/mocktail`
|
||||
and conflict with each other and with upstream's `mocktail` package.
|
||||
Nightcap is in no distribution repository or the AUR; the recipes under
|
||||
`packaging/aur/nightcap{,-bin,-git}` build from this repository locally.
|
||||
- The AppStream metadata lists the Nightcap releases, so software centres
|
||||
show the release history instead of upstream's.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ETC2 texture decoding no longer blocks the thread that submits the frame.
|
||||
Uploads decode on a background dispatcher and the submit carries a timeline
|
||||
semaphore wait, so the GPU waits for the texture instead of the render
|
||||
thread. Reading the application's compressed bytes moves to that dispatcher
|
||||
too whenever they are already mapped. In Blade Ball the worst
|
||||
`vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to 0.05ms, and
|
||||
the total time spent in submits from 2534ms to 309ms. Frame interval is
|
||||
unchanged: the remaining tail is the engine's own main-thread step. A device
|
||||
without the `timelineSemaphore` feature decodes inline as before, as does a
|
||||
submit whose `pNext` already sizes arrays by its semaphore counts.
|
||||
- The mouse pointer jumped when a right-click camera drag ended, and drifted
|
||||
to the viewport edge during one. The input router inferred pointer capture
|
||||
from SDL reporting zero absolute coordinates, which holds on X11 but not on
|
||||
Wayland, where the reported position accumulates the relative deltas and
|
||||
leaves the window. Motion, button, and wheel events now carry the window's
|
||||
relative mouse mode. The right-click camera fallback also only captures
|
||||
inside an experience, so the cursor stays free over the app surface.
|
||||
- Shutting the window down while an experience was starting or leaving could
|
||||
free the pointer capture owner underneath the thread using it. Callers off
|
||||
the SDL thread now hold a reference for the length of their call.
|
||||
- The TextBox caret did not move when a trailing space was typed in a
|
||||
single-line field. The final cluster rect SDL_ttf reports stops at the last
|
||||
inked glyph, so the laid-out width is used there instead. Wrapped layouts
|
||||
keep the clamp, because their width is the widest line rather than the last.
|
||||
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six
|
||||
`TTF_OpenFont` calls, and `TTF_Quit` on the render thread. The overlay holds
|
||||
its opened fonts and reopens them only when the Roblox font or the point
|
||||
size changes, keeping the current set when a reopen fails. Measured over 100
|
||||
rasters, 1.59ms to 0.12ms per keystroke.
|
||||
- Staged ETC2 copies now carry an explicit host-write barrier. A queue
|
||||
submission only makes host writes from before it visible to the device, and
|
||||
an asynchronous decode writes after the submit, so without the barrier those
|
||||
texels were not guaranteed to reach the copy that reads them.
|
||||
|
||||
### Changed
|
||||
|
||||
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A
|
||||
`desiredgov=powersave` in `~/.config/gamemode.ini` overrides the packaged
|
||||
`performance` default and clocks the CPU down for the whole session while
|
||||
still reporting an active performance request.
|
||||
|
||||
## [0.3.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
@@ -135,7 +238,9 @@ First Nightcap release, forked from komaruworld/mocktail.
|
||||
- The AppImage build no longer fails on stale checksums for the slimmed
|
||||
Arch packages.
|
||||
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.0...HEAD
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.2...HEAD
|
||||
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
|
||||
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
|
||||
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.1.0
|
||||
+32
-20
@@ -10,7 +10,7 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(Mocktail
|
||||
VERSION 1.0.4
|
||||
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
|
||||
DESCRIPTION "Android Roblox compatibility runtime for Linux"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
|
||||
@@ -288,6 +288,7 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/roblox_game_session_native_adapter.cc
|
||||
src/runtime/roblox_web_view_bridge.cc
|
||||
src/runtime/roblox_permissions_bridge.cc
|
||||
src/runtime/roblox_account_bridge.cc
|
||||
src/runtime/payload_update_preflight.cc
|
||||
src/runtime/process_launch_policy.cc
|
||||
src/runtime/process_diagnostics.cc
|
||||
@@ -385,7 +386,8 @@ add_library(mocktail_webview_helper_policy STATIC
|
||||
src/webview/webview_helper_policy.cc
|
||||
)
|
||||
add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy)
|
||||
target_include_directories(mocktail_webview_helper_policy PUBLIC src)
|
||||
target_include_directories(mocktail_webview_helper_policy PUBLIC src
|
||||
PRIVATE include)
|
||||
target_link_libraries(mocktail_webview_helper_policy PUBLIC
|
||||
PkgConfig::WEBKITGTK6
|
||||
nlohmann_json::nlohmann_json
|
||||
@@ -650,6 +652,11 @@ target_link_options(mocktail PRIVATE
|
||||
)
|
||||
|
||||
# Native FreeBSD helper for Linuxulator's unsupported DF socket option.
|
||||
# The helper is x86-64 assembly; other host architectures have nothing to
|
||||
# assemble.
|
||||
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$")
|
||||
set(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER OFF)
|
||||
endif()
|
||||
option(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER
|
||||
"Build the native FreeBSD Linuxulator socket helper" ON)
|
||||
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER)
|
||||
@@ -786,33 +793,39 @@ install(CODE "
|
||||
endif()
|
||||
")
|
||||
|
||||
set(MOCKTAIL_PACKAGE_NAMES mocktail mocktail-nightly nightcap nightcap-nightly)
|
||||
set(MOCKTAIL_PACKAGE_NAME "mocktail" CACHE STRING
|
||||
"Native package name"
|
||||
)
|
||||
set(MOCKTAIL_PACKAGE_VERSION "${PROJECT_VERSION}" CACHE STRING
|
||||
"Native package version"
|
||||
)
|
||||
set(MOCKTAIL_PACKAGE_VERSION_SUFFIX "" CACHE STRING
|
||||
"Native package version suffix"
|
||||
)
|
||||
if(NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail" AND
|
||||
NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
|
||||
if(NOT MOCKTAIL_PACKAGE_NAME IN_LIST MOCKTAIL_PACKAGE_NAMES)
|
||||
string(JOIN ", " MOCKTAIL_PACKAGE_NAMES_TEXT ${MOCKTAIL_PACKAGE_NAMES})
|
||||
message(FATAL_ERROR
|
||||
"MOCKTAIL_PACKAGE_NAME must be mocktail or mocktail-nightly")
|
||||
"MOCKTAIL_PACKAGE_NAME must be one of ${MOCKTAIL_PACKAGE_NAMES_TEXT}")
|
||||
endif()
|
||||
|
||||
set(CPACK_PACKAGE_NAME "${MOCKTAIL_PACKAGE_NAME}")
|
||||
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}")
|
||||
set(CPACK_PACKAGE_VENDOR "Mocktail Project")
|
||||
set(CPACK_PACKAGE_CONTACT "komaruworld <rtysus228@gmail.com>")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/komaruworld/mocktail")
|
||||
set(CPACK_PACKAGE_VERSION
|
||||
"${MOCKTAIL_PACKAGE_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}"
|
||||
)
|
||||
set(CPACK_PACKAGE_VENDOR "Nightcap")
|
||||
set(CPACK_PACKAGE_CONTACT "vii <coderdayton14@gmail.com>")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CoderDayton/nightcap")
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}")
|
||||
set(CPACK_PACKAGE_DESCRIPTION
|
||||
"Runs the Android x86-64 Roblox client on Linux."
|
||||
"Runs the Android Roblox client on Linux."
|
||||
)
|
||||
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")
|
||||
set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
|
||||
set(CPACK_STRIP_FILES TRUE)
|
||||
|
||||
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
|
||||
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "komaruworld <rtysus228@gmail.com>")
|
||||
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "vii <coderdayton14@gmail.com>")
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
|
||||
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
|
||||
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
|
||||
@@ -822,15 +835,14 @@ set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
|
||||
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
|
||||
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
|
||||
|
||||
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
|
||||
else()
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
|
||||
endif()
|
||||
# Every variant installs the same /usr/bin/mocktail and /usr/share/mocktail,
|
||||
# so only one of them can be present at a time.
|
||||
set(MOCKTAIL_PACKAGE_SIBLINGS ${MOCKTAIL_PACKAGE_NAMES})
|
||||
list(REMOVE_ITEM MOCKTAIL_PACKAGE_SIBLINGS "${MOCKTAIL_PACKAGE_NAME}")
|
||||
string(JOIN ", " MOCKTAIL_PACKAGE_CONFLICTS ${MOCKTAIL_PACKAGE_SIBLINGS})
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "${MOCKTAIL_PACKAGE_CONFLICTS}")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
|
||||
include(CPack)
|
||||
|
||||
add_dependencies(mocktail all_stubs)
|
||||
|
||||
@@ -42,6 +42,18 @@ attach `latest.log`:
|
||||
|
||||
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
|
||||
|
||||
## Can I run multiple Roblox instances?
|
||||
|
||||
No. Nightcap won't add multi-instance launching. It would mean working around
|
||||
Roblox's restrictions on running several clients. The [Roblox Terms of Use](https://en.help.roblox.com/hc/en-us/articles/115004647846-Roblox-Terms-of-Use)
|
||||
prohibit bypassing technical protections, and violating the terms can get your
|
||||
account suspended.
|
||||
|
||||
Bloxstrap brought the option back in [v2.9.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.9.0)
|
||||
with a "use at your own risk" warning, then removed it in
|
||||
[v2.10.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.10.0)
|
||||
after Roblox added measures against running multiple clients.
|
||||
|
||||
## Can I play in VR?
|
||||
|
||||
VR is experimental and lives on upstream's `vr` branch, which does not
|
||||
|
||||
@@ -21,7 +21,7 @@ ANYLINUX_APPIMAGETOOL ?= appimagetool
|
||||
PREFIX ?= /usr
|
||||
FLATPAK_BUILD_DIR ?= build-flatpak
|
||||
FLATPAK_MANIFEST ?= packaging/flatpak/io.github.CoderDayton.nightcap.json
|
||||
FLATPAK_JOBS ?= 4
|
||||
FLATPAK_JOBS ?= 2
|
||||
|
||||
define build_native_runtime
|
||||
@git submodule update --init --recursive
|
||||
@@ -130,8 +130,8 @@ auto-run: ## Build/run loop with crash summaries
|
||||
run-gles: build ## Run strict system EGL/OpenGL ES 3 without Vulkan fallback
|
||||
@$(BINARY) --graphics opengl
|
||||
|
||||
run-angle: ## Run with ANGLE/Vulkan compatibility backend
|
||||
@MOCKTAIL_GRAPHICS_BACKEND=angle-vulkan ./scripts/run_sober.sh
|
||||
run-angle: build ## Run with ANGLE/Vulkan compatibility backend
|
||||
@$(BINARY) --graphics angle-vulkan
|
||||
|
||||
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
|
||||
@./scripts/update_roblox_payload.sh
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
|
||||
# Nightcap
|
||||
|
||||
[](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml)
|
||||
[](https://github.com/CoderDayton/nightcap/stargazers)
|
||||
[](LICENSE)
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
|
||||
<h1 style="margin-top: 5px;">
|
||||
Nightcap
|
||||
<br>
|
||||
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
||||
</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
|
||||
@@ -61,10 +66,24 @@ chmod +x Nightcap-x86_64.AppImage
|
||||
./Nightcap-x86_64.AppImage
|
||||
```
|
||||
|
||||
Or build and install it from source using the steps under
|
||||
[Building](#building). There are no AUR, DEB, or RPM packages of Nightcap.
|
||||
Upstream ships those for unmodified Mocktail, see
|
||||
[komaruworld/mocktail](https://github.com/komaruworld/mocktail#readme).
|
||||
## Distribution packages
|
||||
|
||||
Each release also carries a DEB, an RPM, and a pacman package, with a
|
||||
`.sha256` beside every file on the
|
||||
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
|
||||
|
||||
```bash
|
||||
sudo apt install ./nightcap_<version>_amd64.deb
|
||||
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
|
||||
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
|
||||
package. Nightcap is not in any distribution repository or the AUR; the
|
||||
recipes under `packaging/aur` are kept for local builds only.
|
||||
|
||||
Or build and install from source using the steps under
|
||||
[Building](#building).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -116,8 +135,9 @@ Linux `x86_64` is supported. Experimental FreeBSD 15.1 Linuxulator support has
|
||||
been tested with an `x86_64` Fedora 44 userspace. On FreeBSD, Nightcap runs
|
||||
inside Linuxulator; it is not a native FreeBSD binary. Building requires CMake
|
||||
3.20+, Git, pkg-config, LLD, binutils, a C++17 compiler, SDL 3.4+, SDL3_ttf,
|
||||
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, minizip,
|
||||
Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and WebKitGTK 6.0.
|
||||
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, libpng,
|
||||
minizip, Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and
|
||||
WebKitGTK 6.0.
|
||||
|
||||
See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instructions.
|
||||
|
||||
@@ -128,7 +148,7 @@ See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instru
|
||||
sudo apt update
|
||||
sudo apt install build-essential cmake git ninja-build pkg-config lld \
|
||||
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
|
||||
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
||||
nlohmann-json3-dev libyaml-dev libpng-dev libelf-dev libminizip-dev \
|
||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
||||
libplacebo-dev zlib1g-dev
|
||||
@@ -140,7 +160,7 @@ sudo apt install build-essential cmake git ninja-build pkg-config lld \
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
|
||||
curl openssl nlohmann-json libyaml libelf minizip capstone gtk4 \
|
||||
curl openssl nlohmann-json libyaml libpng libelf minizip capstone gtk4 \
|
||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
|
||||
libplacebo vulkan-headers vulkan-icd-loader zlib
|
||||
```
|
||||
@@ -152,7 +172,8 @@ sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
|
||||
```bash
|
||||
sudo dnf install gcc-c++ cmake git ninja-build pkgconf-pkg-config lld \
|
||||
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
|
||||
nlohmann-json-devel libyaml-devel elfutils-libelf-devel minizip-ng-compat-devel \
|
||||
nlohmann-json-devel libyaml-devel libpng-devel elfutils-libelf-devel \
|
||||
minizip-ng-compat-devel \
|
||||
capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \
|
||||
utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \
|
||||
vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel
|
||||
@@ -176,6 +197,11 @@ Use `sudo make install` instead to install system-wide under `/usr`. To make
|
||||
Roblox website links open the build tree copy without installing, run
|
||||
`make register-url-handler`.
|
||||
|
||||
To build and install the Flatpak from source, install `flatpak-builder` and run
|
||||
`make flatpak`. It builds with 2 parallel jobs to keep memory use low. Pass
|
||||
`FLATPAK_JOBS=N` to `make flatpak`, or set `MOCKTAIL_FLATPAK_JOBS=N` for
|
||||
`scripts/build_flatpak.sh`, to change that.
|
||||
|
||||
To run the same checks as CI before each commit and push, install
|
||||
[lefthook](https://github.com/evilmartians/lefthook) and run once:
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
"version_name": "2.721.1108",
|
||||
"version_code": 2350,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "50e1b0abd123350e794226062fe3a1ef360c5f0d",
|
||||
"status": "legacy-researched",
|
||||
"default_allowed": false,
|
||||
@@ -15,6 +16,7 @@
|
||||
{
|
||||
"version_name": "2.725.1142",
|
||||
"version_code": 2546,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -35,6 +37,7 @@
|
||||
{
|
||||
"version_name": "2.727.1199",
|
||||
"version_code": 2628,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -46,6 +49,7 @@
|
||||
{
|
||||
"version_name": "2.734.917",
|
||||
"version_code": 2908,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
@@ -66,6 +70,19 @@
|
||||
{
|
||||
"version_name": "2.736.1408",
|
||||
"version_code": 2998,
|
||||
"abi": "arm64-v8a",
|
||||
"elf_build_id": "ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
"allow_legacy_binary_patches": false,
|
||||
"allow_host_abi_bridges": true,
|
||||
"allow_host_constructor_replay": true,
|
||||
"reason": "AArch64 arm64-v8a payload of the same 2.736.1408 release as the verified x86-64 profile; allocator, constructor replay, and pre-JNI bootstrap RVAs were derived by cross-architecture semantic matching."
|
||||
},
|
||||
{
|
||||
"version_name": "2.736.1408",
|
||||
"version_code": 2998,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
|
||||
+27
-2
@@ -44,16 +44,30 @@ Each slice is named after the call it times, on the thread that made it.
|
||||
| `present` | `host present`: the host driver's present, nested inside the slice above | |
|
||||
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
|
||||
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
|
||||
| `texture` | `etc2 decode`: CPU decode of ETC2 textures before a submit | `uploads`, `compressed_bytes`, `decoded_bytes` |
|
||||
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
|
||||
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
|
||||
| `texture` | `etc2 staging create`: creating a staging block for ETC2 uploads, when no block has room left | `bytes` |
|
||||
| `texture` | `etc2 staging destroy`: destroying empty staging blocks beyond the four kept idle | `buffers` |
|
||||
| `texture` | `etc2 release wait`: a command buffer reset waiting for its uploads' decode. Only waits of 0.1 ms or more are recorded | `ticket` |
|
||||
| `memory` | `vkAllocateMemory` | `bytes`, `type` (memory type index), `result` |
|
||||
| `memory` | `vkFreeMemory`, `vkUnmapMemory`: both include waiting for ETC2 decodes that still read that memory | |
|
||||
| `memory` | `vkMapMemory`, `vkCreateImage`, `vkDestroyImage` | |
|
||||
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
|
||||
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
|
||||
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
|
||||
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | |
|
||||
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | `cpu_us`: CPU time the main thread used during the call. Far below the slice's duration means the step spent it waiting |
|
||||
|
||||
The `frame interval (ms)` counter is the time between successive adapter
|
||||
presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's
|
||||
own cost per frame.
|
||||
|
||||
Two more counters cover the same interval. `present thread cpu (ms)` is the
|
||||
CPU time the thread that presents used in it, and is absent for a frame
|
||||
presented from a different thread than the one before. `process cpu (ms)` is
|
||||
the CPU time of every thread together, so dividing it by the frame interval
|
||||
gives the number of cores busy. A long frame with little present-thread CPU
|
||||
was spent waiting.
|
||||
|
||||
## The benchmark runs
|
||||
|
||||
Run every scenario before and after the change, on the same build type
|
||||
@@ -61,6 +75,17 @@ Run every scenario before and after the change, on the same build type
|
||||
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
|
||||
because a frame cap hides frame time gains.
|
||||
|
||||
Record `~/.config/gamemode.ini` too, and do not change it between the before
|
||||
and after runs. `desiredgov` there decides the CPU governor for the whole
|
||||
session and therefore which engine pump mode is chosen, which moves process
|
||||
CPU by more than most changes under test. See
|
||||
[PERFORMANCE.md](PERFORMANCE.md).
|
||||
|
||||
Frame interval p99 varies by 2 ms or more between identical runs, so a
|
||||
difference that size is not a result. Three runs per scenario is the minimum
|
||||
for any frame-time claim; slice totals for a specific subsystem are far less
|
||||
noisy and can be read from fewer.
|
||||
|
||||
| Scenario | How to start | What to do | What it shows |
|
||||
| --- | --- | --- | --- |
|
||||
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
|
||||
|
||||
+48
-2
@@ -27,20 +27,48 @@ request. If GameMode narrowed it, Nightcap ends the request and prints:
|
||||
```
|
||||
|
||||
The trade is that GameMode's governor and priority boost go with it. To keep
|
||||
those and lose only the pinning, turn pinning off for every game on the host:
|
||||
those and lose only the pinning, turn pinning off for every game on the host.
|
||||
|
||||
These are the settings to run with:
|
||||
|
||||
```ini
|
||||
# ~/.config/gamemode.ini
|
||||
[general]
|
||||
desiredgov=performance
|
||||
|
||||
[cpu]
|
||||
pin_cores=no
|
||||
park_cores=no
|
||||
```
|
||||
|
||||
`desiredgov` is the one to check first. The packaged default in
|
||||
`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
|
||||
your own file replaces it, and `desiredgov=powersave` there means GameMode
|
||||
clocks the CPU **down** for the whole session while still reporting
|
||||
`[gamemode] performance request active`. The symptoms are a low CPU clock
|
||||
under load, a GPU that never leaves its lower SM clocks, and the engine pump
|
||||
falling back to `tpause` — which costs about 70% of a core instead of 3%,
|
||||
because `sleep` is only chosen when the governor reads `performance`. On one
|
||||
13900K host, correcting `powersave` to `performance` took the process from
|
||||
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
|
||||
|
||||
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
|
||||
order. It does not read anything under Nightcap's own config directory, so this
|
||||
file has to live in one of those three places. The daemon reads it when it
|
||||
starts, so restart it after editing: `systemctl --user restart gamemoded`.
|
||||
|
||||
Restarting the daemon drops the session of any game already running; relaunch
|
||||
the game for new settings to apply to it.
|
||||
|
||||
`gamemoded` is D-Bus activated through
|
||||
`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
|
||||
does not hold — the next client request starts it again within seconds. To
|
||||
measure with GameMode genuinely off, mask it:
|
||||
|
||||
```bash
|
||||
systemctl --user mask gamemoded # and --unmask afterwards
|
||||
```
|
||||
|
||||
The governor switch runs through polkit. Debian and Ubuntu only allow it for
|
||||
members of the `gamemode` group; without that, the daemon's journal shows
|
||||
`cpugovctl set performance: Not authorized` and the governor stays put while
|
||||
@@ -59,9 +87,20 @@ to the affinity. `off` never contacts the daemon.
|
||||
To see which CPUs the process may use:
|
||||
|
||||
```bash
|
||||
taskset -pc $(pgrep -x mocktail)
|
||||
pid=$(pgrep -x Main)
|
||||
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
|
||||
```
|
||||
|
||||
Two traps here. The engine process reports its name as `Main`, not
|
||||
`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
|
||||
CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
|
||||
process, so a single call reports the main thread's mask and says nothing
|
||||
about the workers — which is where the game's load actually is.
|
||||
|
||||
For the same reason, `ps -L -o psr=` shows which core each thread is running
|
||||
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
|
||||
preferring P-cores for runnable work is normal and is not a mask.
|
||||
|
||||
## The main-thread pump
|
||||
|
||||
Roblox posts its per-frame main-thread step at a time of its own choosing,
|
||||
@@ -99,6 +138,13 @@ threads while textures load, which shows up as a burst rather than a steady
|
||||
load. The enlarged textures then stay in GPU memory for the session and cost
|
||||
extra sampling bandwidth on every frame that uses them.
|
||||
|
||||
That decode does not run on the thread that submits the frame. On a device
|
||||
with a timeline semaphore, uploads decode on a background dispatcher and the
|
||||
submit carries a semaphore wait, so the GPU waits for the texture instead of
|
||||
the render thread. In the trace this is the `etc2 decode` slice on its own
|
||||
thread, with only a negligible `etc2 gather` left on the submitting one. A
|
||||
device without that feature decodes inline as before.
|
||||
|
||||
Set it to `1` to turn upscaling off:
|
||||
|
||||
```bash
|
||||
|
||||
+26
-35
@@ -16,7 +16,7 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
|
||||
- Input tab: the `input.raw_mouse` toggle and controller options.
|
||||
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
|
||||
- Discord tab: every field in the `discord_rpc` block, so a friend can see
|
||||
what you are playing and you can switch it all off in one click.
|
||||
what you are playing and you can switch it all off in one click.
|
||||
- Changes apply on next launch. A restart button restarts Nightcap.
|
||||
|
||||
Done when every option in `config/mocktail.example.yaml` that a player would
|
||||
@@ -29,62 +29,53 @@ 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.
|
||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
||||
and product IDs, are available and unused.
|
||||
|
||||
Done when a plugged-in pad shows its own buttons.
|
||||
|
||||
## 3. First-load stutter
|
||||
|
||||
The `--profile` trace now shows shader compiles and pipeline creation, so
|
||||
the stutter on first entering an experience can be measured.
|
||||
|
||||
- Cut the stutter the trace shows, and prove it with a before and after
|
||||
trace per [BENCHMARKING.md](BENCHMARKING.md).
|
||||
|
||||
Done when entering a fresh experience no longer drops frames on shader
|
||||
compiles.
|
||||
|
||||
## Shipped
|
||||
|
||||
- Lower input latency: frames are presented as soon as they are ready.
|
||||
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
|
||||
releases its held buttons and sticks instead of leaving them stuck.
|
||||
releases its held buttons and sticks instead of leaving them stuck.
|
||||
- Mouse look keeps responding after a long turn, instead of going dead until
|
||||
the pointer travels back from past the screen edge.
|
||||
the pointer travels back from past the screen edge.
|
||||
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
|
||||
deltas in host units, so fractional display scaling no longer changes
|
||||
first-person sensitivity.
|
||||
deltas in host units, so fractional display scaling no longer changes
|
||||
first-person sensitivity.
|
||||
- Small textures upscaled 4x by default, with PNG overrides in
|
||||
`~/.config/mocktail/textures`.
|
||||
`~/.config/mocktail/textures`.
|
||||
- ETC2 textures decoded on desktop GPUs without stutter.
|
||||
- No more error 319 kicks after joining a server.
|
||||
- Roblox sees the real host RAM and screen size.
|
||||
- Desktop launcher that works on Wayland and finds the installed binary.
|
||||
- Discord Rich Presence on by default, with the experience name, creator,
|
||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
||||
- Custom Discord presence: title, details, state, and large and small icons
|
||||
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
|
||||
`{creator_name}` placeholders.
|
||||
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
|
||||
`{creator_name}` placeholders.
|
||||
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
|
||||
including shader and pipeline creation, readable in `ui.perfetto.dev`.
|
||||
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
|
||||
`scripts/summarize_profile_trace.py` builds the before and after table.
|
||||
including shader and pipeline creation, readable in `ui.perfetto.dev`.
|
||||
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
|
||||
`scripts/summarize_profile_trace.py` builds the before and after table.
|
||||
- Texture uploads no longer stall the frame they land on: ETC2 decode and
|
||||
resampling run on a persistent worker pool.
|
||||
resampling run on a persistent worker pool.
|
||||
- Less stutter while joining an experience and while textures load in. See
|
||||
the [changelog](../CHANGELOG.md) for the numbers.
|
||||
- The host main thread rests between engine polls instead of spinning, and
|
||||
GameMode's core pinning is released, so Roblox's workers spread across
|
||||
every core. See [PERFORMANCE.md](PERFORMANCE.md).
|
||||
GameMode's core pinning is released, so Roblox's workers spread across
|
||||
every core. See [PERFORMANCE.md](PERFORMANCE.md).
|
||||
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
||||
upstream.
|
||||
upstream.
|
||||
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
|
||||
repo on GitHub Pages.
|
||||
repo on GitHub Pages.
|
||||
|
||||
## Not planned
|
||||
|
||||
- Replacing the `mocktail` binary and config names. Keeping them makes
|
||||
upstream merges clean.
|
||||
- DEB, RPM, and AUR packages. Upstream ships those for unmodified Mocktail.
|
||||
upstream merges clean.
|
||||
|
||||
@@ -33,6 +33,23 @@ void SetLegacyBionicDiagnosticsEnabled(bool enabled);
|
||||
// guest layout explicit instead of exposing the incompatible host libc type.
|
||||
using MocktailBionicMutexAttr = int64_t;
|
||||
|
||||
// Bionic LP64 pthread_attr_t: 56 bytes with explicit field offsets. The host
|
||||
// glibc layout is opaque and wider on aarch64, so guest buffers must be read
|
||||
// and written through this struct only.
|
||||
struct MocktailBionicPthreadAttr {
|
||||
std::uint32_t flags;
|
||||
void* stack_base;
|
||||
std::size_t stack_size;
|
||||
std::size_t guard_size;
|
||||
std::int32_t sched_policy;
|
||||
std::int32_t sched_priority;
|
||||
char reserved[16];
|
||||
};
|
||||
static_assert(sizeof(MocktailBionicPthreadAttr) == 56);
|
||||
|
||||
inline constexpr std::uint32_t kMocktailBionicAttrFlagDetached = 0x1;
|
||||
inline constexpr std::uint32_t kMocktailBionicAttrFlagUserStack = 0x2;
|
||||
|
||||
extern "C" {
|
||||
|
||||
void mocktail_set_current_jni_env(void* env);
|
||||
@@ -84,6 +101,19 @@ int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
|
||||
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
|
||||
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
|
||||
|
||||
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
|
||||
size_t stack_size);
|
||||
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
|
||||
int detach_state);
|
||||
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
|
||||
const struct sched_param* parameters);
|
||||
int mocktail_pthread_getattr_np(pthread_t thread,
|
||||
MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
|
||||
void** stack_base, size_t* stack_size);
|
||||
|
||||
extern void* mocktail_gameactivity_on_start_native;
|
||||
extern void* mocktail_gameactivity_on_resume_native;
|
||||
extern void* mocktail_gameactivity_on_surface_created_native;
|
||||
|
||||
@@ -25,11 +25,12 @@ void ConfigureBionicPthreadThreadInitializer(
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
struct MocktailBionicPthreadAttr;
|
||||
|
||||
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
|
||||
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
|
||||
const pthread_attr_t* attr,
|
||||
void* (*start_routine)(void*),
|
||||
void* argument);
|
||||
extern "C" int mocktail_bionic_pthread_create(
|
||||
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument);
|
||||
|
||||
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
|
||||
extern "C" int mocktail_bionic_pthread_setschedparam(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
#define MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
|
||||
#include <elf.h>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
#if defined(__aarch64__)
|
||||
// Android ABI name as it appears in APK lib/ directories and build metadata.
|
||||
inline constexpr std::string_view kGuestAbi = "arm64-v8a";
|
||||
// APK split-config name carrying the guest native libraries.
|
||||
inline constexpr std::string_view kGuestApkSplitName = "config.arm64_v8a";
|
||||
// File name the payload store uses for the split APK.
|
||||
inline constexpr std::string_view kGuestSplitApkFile =
|
||||
"split_config.arm64_v8a.apk";
|
||||
// sha256 map key holding the split APK digest in payload metadata.
|
||||
inline constexpr std::string_view kGuestSplitApkHashKey = "arm64_v8a_split_apk";
|
||||
// Short CPU label for device metadata and user-facing strings.
|
||||
inline constexpr std::string_view kGuestCpuName = "aarch64";
|
||||
inline constexpr int kGuestElfMachine = EM_AARCH64;
|
||||
#elif defined(__x86_64__)
|
||||
inline constexpr std::string_view kGuestAbi = "x86_64";
|
||||
inline constexpr std::string_view kGuestApkSplitName = "config.x86_64";
|
||||
inline constexpr std::string_view kGuestSplitApkFile = "split_config.x86_64.apk";
|
||||
inline constexpr std::string_view kGuestSplitApkHashKey = "x86_64_split_apk";
|
||||
inline constexpr std::string_view kGuestCpuName = "x86_64";
|
||||
inline constexpr int kGuestElfMachine = EM_X86_64;
|
||||
#else
|
||||
#error "mocktail only supports aarch64 and x86_64 hosts"
|
||||
#endif
|
||||
|
||||
// APK zip entry holding the guest libroblox.so.
|
||||
inline constexpr std::string_view kGuestLibApkEntryPrefix = "lib/";
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
@@ -0,0 +1,190 @@
|
||||
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
|
||||
// stack slot, then reads the protocol from the returned holder:
|
||||
//
|
||||
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
|
||||
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
|
||||
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
|
||||
// ldr x8, [xN, #0x18]
|
||||
//
|
||||
// These functions find that call site in the initializer's first bytes and
|
||||
// check the getter's own code. Only relocated or build-varying fields (stack
|
||||
// slots, frame sizes, PC-relative offsets, the call target) are masked.
|
||||
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
|
||||
// 2.738.1397 and 2.740.931.
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
|
||||
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
|
||||
|
||||
namespace web_view_protocol_contract_detail {
|
||||
|
||||
inline bool MatchesMasked(std::string_view code, std::size_t offset,
|
||||
std::string_view pattern,
|
||||
std::string_view wildcard) {
|
||||
if (offset > code.size() || code.size() - offset < pattern.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t index = 0; index < pattern.size(); ++index) {
|
||||
if (wildcard[index] == 'x') continue;
|
||||
if (code[offset + index] != pattern[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// AArch64 instructions are little-endian words.
|
||||
inline std::uint32_t Word(std::string_view code, std::size_t index) {
|
||||
std::uint32_t word = 0;
|
||||
for (std::size_t byte = 0; byte < 4; ++byte) {
|
||||
word |= static_cast<std::uint32_t>(
|
||||
static_cast<unsigned char>(code[index * 4 + byte]))
|
||||
<< (8 * byte);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
// One AArch64 instruction word: bits outside `mask` may vary.
|
||||
struct MaskedWord {
|
||||
std::uint32_t mask;
|
||||
std::uint32_t value;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kExact = 0xffffffffU;
|
||||
// adrp Xd, page: immlo and immhi vary.
|
||||
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
|
||||
// add Xd, Xn, #imm12: imm12 varies.
|
||||
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
|
||||
|
||||
} // namespace web_view_protocol_contract_detail
|
||||
|
||||
// Returns the getter address called from the x86-64 initializer at
|
||||
// `address`, or nullopt unless the frame setup and exactly one call site
|
||||
// match.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::MatchesMasked;
|
||||
// push %rbp; mov %rsp,%rbp
|
||||
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
|
||||
return std::nullopt;
|
||||
}
|
||||
static constexpr std::string_view kCallSite(
|
||||
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
|
||||
"\x18",
|
||||
19);
|
||||
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
|
||||
static constexpr std::size_t kCallDisplacement = 8;
|
||||
const std::string_view window =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes);
|
||||
std::optional<std::size_t> call_site;
|
||||
for (std::size_t offset = 0; offset < window.size(); ++offset) {
|
||||
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
|
||||
if (call_site.has_value()) return std::nullopt;
|
||||
call_site = offset;
|
||||
}
|
||||
}
|
||||
if (!call_site.has_value()) return std::nullopt;
|
||||
std::int32_t displacement = 0;
|
||||
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
|
||||
sizeof(displacement));
|
||||
const std::uintptr_t next_instruction =
|
||||
address + *call_site + kCallDisplacement + sizeof(displacement);
|
||||
return next_instruction +
|
||||
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
|
||||
}
|
||||
|
||||
// The x86-64 getter stores the holder returned by the subsystem's acquire
|
||||
// into its result slot (%rdi) and returns that slot.
|
||||
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
|
||||
return web_view_protocol_contract_detail::MatchesMasked(
|
||||
code, 0,
|
||||
std::string_view(
|
||||
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
|
||||
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
|
||||
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
|
||||
kWebViewProtocolGetterBytesX86_64),
|
||||
"...........xxxx.......xxxx..................");
|
||||
}
|
||||
|
||||
// Returns the getter address called from the AArch64 initializer at
|
||||
// `address`, or nullopt unless exactly one call site matches.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::Word;
|
||||
const std::size_t words =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
|
||||
std::optional<std::uintptr_t> getter;
|
||||
for (std::size_t index = 0; index + 4 <= words; ++index) {
|
||||
const std::uint32_t slot_address = Word(code, index);
|
||||
const std::uint32_t call = Word(code, index + 1);
|
||||
const std::uint32_t handle_load = Word(code, index + 2);
|
||||
const std::uint32_t protocol_load = Word(code, index + 3);
|
||||
// add x8, sp, #slot
|
||||
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
|
||||
// bl getter
|
||||
if ((call & 0xfc000000U) != 0x94000000U) continue;
|
||||
// ldr xN, [sp, #slot]
|
||||
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
|
||||
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
|
||||
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
|
||||
// ldr x8, [xN, #0x18]
|
||||
const std::uint32_t handle_register = handle_load & 0x1fU;
|
||||
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
|
||||
if (getter.has_value()) return std::nullopt;
|
||||
// imm26 is a signed word offset from the bl instruction.
|
||||
const std::int64_t imm26 = call & 0x03ffffffU;
|
||||
const std::int64_t words_from_call =
|
||||
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
|
||||
getter = address + (index + 1) * 4 +
|
||||
static_cast<std::uintptr_t>(words_from_call * 4);
|
||||
}
|
||||
return getter;
|
||||
}
|
||||
|
||||
// The AArch64 getter saves its result slot (x8) and stores the holder
|
||||
// returned by the subsystem's acquire there.
|
||||
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
|
||||
using namespace web_view_protocol_contract_detail;
|
||||
static constexpr MaskedWord kGetter[] = {
|
||||
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
|
||||
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
|
||||
{kExact, 0x910003fdU}, // mov x29, sp
|
||||
{kAdrpMask, 0x90000009U}, // adrp x9, guard
|
||||
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
|
||||
{kExact, 0xaa0803f3U}, // mov x19, x8
|
||||
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
|
||||
{kExact, 0x36000109U}, // tbz w9, #0, initialize
|
||||
{kAdrpMask, 0x90000000U}, // adrp x0, holder
|
||||
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
|
||||
{kExact, 0x94000017U}, // bl acquire
|
||||
{kExact, 0xf9000260U}, // str x0, [x19]
|
||||
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
|
||||
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
|
||||
{kExact, 0xd65f03c0U}, // ret
|
||||
};
|
||||
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
|
||||
kWebViewProtocolGetterBytesArm64);
|
||||
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
|
||||
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
|
||||
++index) {
|
||||
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
@@ -48,9 +48,15 @@ struct PlatformIdentity {
|
||||
std::string manufacturer = "Mocktail";
|
||||
std::string model = "Mocktail Headless";
|
||||
std::string brand = "Mocktail";
|
||||
#if defined(__aarch64__)
|
||||
std::string device_code = "linux-arm64";
|
||||
std::string device_sku = "mocktail-arm64";
|
||||
std::string soc_model = "aarch64";
|
||||
#else
|
||||
std::string device_code = "linux-x86_64";
|
||||
std::string device_sku = "mocktail-x86_64";
|
||||
std::string soc_model = "x86_64";
|
||||
#endif
|
||||
};
|
||||
|
||||
struct RobloxCredentialView {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#if defined(__x86_64__)
|
||||
#include <xmmintrin.h>
|
||||
#endif
|
||||
#if defined(__aarch64__)
|
||||
#include <cstdint>
|
||||
#endif
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
@@ -22,16 +25,23 @@ class FmodThreadFloatingPointMode final {
|
||||
delete;
|
||||
|
||||
bool Enable(const char* thread_name) noexcept {
|
||||
#if defined(__x86_64__)
|
||||
#if defined(__x86_64__) || defined(__aarch64__)
|
||||
if (active_ || thread_name == nullptr) return false;
|
||||
const std::string_view name(thread_name);
|
||||
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
|
||||
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
|
||||
return false;
|
||||
}
|
||||
#if defined(__x86_64__)
|
||||
const unsigned int control = _mm_getcsr();
|
||||
original_mode_ = control & kModeMask;
|
||||
_mm_setcsr(control | kModeMask);
|
||||
#else
|
||||
std::uint64_t control;
|
||||
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
|
||||
original_mode_ = control & kModeMask;
|
||||
__asm__ volatile("msr fpcr, %0" ::"r"(control | kModeMask));
|
||||
#endif
|
||||
active_ = true;
|
||||
return true;
|
||||
#else
|
||||
@@ -41,11 +51,18 @@ class FmodThreadFloatingPointMode final {
|
||||
}
|
||||
|
||||
void Restore() noexcept {
|
||||
#if defined(__x86_64__)
|
||||
#if defined(__x86_64__) || defined(__aarch64__)
|
||||
if (active_) {
|
||||
// Restore only our two bits, preserving rounding, exception masks and
|
||||
// status flags set by the guest while the thread was attached.
|
||||
#if defined(__x86_64__)
|
||||
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
|
||||
#else
|
||||
std::uint64_t control;
|
||||
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
|
||||
__asm__ volatile("msr fpcr, %0" ::"r"((control & ~kModeMask) |
|
||||
original_mode_));
|
||||
#endif
|
||||
active_ = false;
|
||||
}
|
||||
#endif
|
||||
@@ -56,6 +73,13 @@ class FmodThreadFloatingPointMode final {
|
||||
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
|
||||
unsigned int original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#elif defined(__aarch64__)
|
||||
// FPCR FZ flushes single/double subnormals; FZ16 covers the half-precision
|
||||
// path used by some voice DSP filters.
|
||||
static constexpr std::uint64_t kModeMask =
|
||||
(std::uint64_t{1} << 24) | (std::uint64_t{1} << 19);
|
||||
std::uint64_t original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,35 @@ struct TraceArg {
|
||||
|
||||
// CLOCK_MONOTONIC in nanoseconds.
|
||||
std::uint64_t TraceClockNanos();
|
||||
// CPU time used so far by the calling thread, in nanoseconds.
|
||||
std::uint64_t TraceThreadCpuNanos();
|
||||
// CPU time used so far by every thread of the process, in nanoseconds.
|
||||
std::uint64_t TraceProcessCpuNanos();
|
||||
|
||||
// CPU time spent between two successive presents.
|
||||
struct FrameCpuSample {
|
||||
double process_ms = 0;
|
||||
// Set only when the previous present came from the same thread; thread CPU
|
||||
// clocks of different threads cannot be subtracted.
|
||||
bool has_thread = false;
|
||||
double thread_ms = 0;
|
||||
};
|
||||
|
||||
// Turns CPU clocks read at each present into per-frame CPU time.
|
||||
// Thread-safe.
|
||||
class FrameCpuSampler final {
|
||||
public:
|
||||
// Returns false for the first present, which ends no frame.
|
||||
bool Sample(std::uint64_t thread_id, std::uint64_t thread_cpu_ns,
|
||||
std::uint64_t process_cpu_ns, FrameCpuSample* sample);
|
||||
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
bool has_previous_ = false;
|
||||
std::uint64_t previous_thread_id_ = 0;
|
||||
std::uint64_t previous_thread_cpu_ns_ = 0;
|
||||
std::uint64_t previous_process_cpu_ns_ = 0;
|
||||
};
|
||||
|
||||
// Writes the Trace Event Format JSON array read by chrome://tracing and
|
||||
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
|
||||
|
||||
@@ -47,17 +47,30 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
|
||||
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
|
||||
std::uint32_t SmallTextureUpscale(const char* value);
|
||||
|
||||
// Staging for decoded uploads is carved out of host-visible blocks this
|
||||
// large; an upload that needs more gets a block of its own.
|
||||
inline constexpr VkDeviceSize kEtc2StagingBlockBytes = 16 * 1024 * 1024;
|
||||
|
||||
// A timeline semaphore and the value an asynchronous decode will signal.
|
||||
// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
|
||||
// calling thread and the submit needs no extra wait.
|
||||
struct Etc2SubmitWait {
|
||||
VkSemaphore semaphore = VK_NULL_HANDLE;
|
||||
std::uint64_t value = 0;
|
||||
};
|
||||
|
||||
// Reports ETC2/EAC support on hosts without native ETC2 (desktop GPUs) and
|
||||
// stores those textures decoded. Images are created with the host format;
|
||||
// each staged upload into one is redirected to a host-visible staging buffer
|
||||
// that is filled by decoding the application's staging bytes when the
|
||||
// command buffer is submitted. Every submit decodes again, as a native copy
|
||||
// re-reads its source on each execution.
|
||||
// Staging buffers live until their command buffer is begun, reset or freed.
|
||||
// Staging ranges live until their command buffer is begun, reset or freed.
|
||||
// MOCKTAIL_DISABLE_ETC2_EMULATION=1 turns the emulation off.
|
||||
class VulkanEtc2Emulation final {
|
||||
public:
|
||||
VulkanEtc2Emulation();
|
||||
explicit VulkanEtc2Emulation(
|
||||
VkDeviceSize staging_block_bytes = kEtc2StagingBlockBytes);
|
||||
~VulkanEtc2Emulation();
|
||||
VulkanEtc2Emulation(const VulkanEtc2Emulation&) = delete;
|
||||
VulkanEtc2Emulation& operator=(const VulkanEtc2Emulation&) = delete;
|
||||
@@ -66,10 +79,14 @@ class VulkanEtc2Emulation final {
|
||||
bool PhysicalDeviceNeedsEmulation(VkPhysicalDevice physical_device,
|
||||
PFN_vkGetPhysicalDeviceFeatures host);
|
||||
|
||||
// `timeline_semaphore` states whether the device was created with the
|
||||
// timelineSemaphore feature enabled. Without it every decode runs on the
|
||||
// calling thread.
|
||||
void RegisterDevice(VkDevice device, VkPhysicalDevice physical_device,
|
||||
bool emulated,
|
||||
const VkPhysicalDeviceMemoryProperties& memory,
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr);
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr,
|
||||
bool timeline_semaphore = false);
|
||||
void DestroyDevice(VkDevice device);
|
||||
|
||||
VkResult CreateImage(VkDevice device, const VkImageCreateInfo* create_info,
|
||||
@@ -112,12 +129,27 @@ class VulkanEtc2Emulation final {
|
||||
const VkCommandBuffer* secondaries);
|
||||
|
||||
// Decodes every upload recorded into these command buffers and the
|
||||
// secondary command buffers they execute. Large batches decode on worker
|
||||
// threads; this returns once every upload is decoded.
|
||||
void PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
std::uint32_t count);
|
||||
// secondary command buffers they execute.
|
||||
//
|
||||
// With `allow_async` on a device that has a timeline semaphore, the decode
|
||||
// runs on worker threads, and so does the gather of the compressed bytes
|
||||
// whenever the application already has them mapped. The returned semaphore
|
||||
// and value must then be added to the submit's waits, so the GPU copy
|
||||
// cannot execute before the decode finishes. Values are signalled in the
|
||||
// order they are handed out.
|
||||
//
|
||||
// Those threads read the application's source bytes after this returns,
|
||||
// which Vulkan allows: the application may not write them between the
|
||||
// submit and the copy's completion. Unmapping or freeing that memory waits
|
||||
// for the decodes still reading it.
|
||||
//
|
||||
// Returns `{VK_NULL_HANDLE, 0}` when there was nothing to decode, when
|
||||
// `allow_async` is false, or when the device has no timeline semaphore. In
|
||||
// those cases every upload is already decoded when this returns.
|
||||
Etc2SubmitWait PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
std::uint32_t count, bool allow_async = false);
|
||||
// Destroys the staging buffers owned by a command buffer that is no longer
|
||||
// pending.
|
||||
// pending, after waiting for any decode still writing into them.
|
||||
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
|
||||
|
||||
struct State;
|
||||
|
||||
@@ -152,6 +152,10 @@ struct MouseMotionEvent {
|
||||
float delta_x = 0.0f;
|
||||
float delta_y = 0.0f;
|
||||
std::uint32_t buttons = 0;
|
||||
// True while the window holds relative mouse mode. The absolute coordinates
|
||||
// are meaningless then: X11 reports zeroes, Wayland reports a position that
|
||||
// accumulates the deltas and can leave the window entirely.
|
||||
bool relative = false;
|
||||
};
|
||||
|
||||
struct MouseButtonEvent {
|
||||
@@ -160,6 +164,8 @@ struct MouseButtonEvent {
|
||||
std::uint8_t clicks = 0;
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
// See MouseMotionEvent::relative.
|
||||
bool relative = false;
|
||||
};
|
||||
|
||||
struct MouseWheelEvent {
|
||||
@@ -167,6 +173,8 @@ struct MouseWheelEvent {
|
||||
float delta_y = 0.0f;
|
||||
float mouse_x = 0.0f;
|
||||
float mouse_y = 0.0f;
|
||||
// See MouseMotionEvent::relative.
|
||||
bool relative = false;
|
||||
};
|
||||
|
||||
struct TouchEvent {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Exact JNI entrypoints behind the APK AccountProtocol device-integrity
|
||||
// handlers: JNIAccountProtocol name getters and MessageBus raw handlers.
|
||||
struct RobloxAccountProtocolSymbols {
|
||||
using GetStringFn = jstring (*)(JNIEnv*, jclass);
|
||||
|
||||
GetStringFn get_protocol_name = nullptr;
|
||||
GetStringFn get_device_integrity_available_method_name = nullptr;
|
||||
GetStringFn get_integrity_token_method_name = nullptr;
|
||||
GetStringFn get_support_key = nullptr;
|
||||
GetStringFn get_token_key = nullptr;
|
||||
GetStringFn get_result_key = nullptr;
|
||||
void (*set_request_handler_raw)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_request_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_protocol_name && get_device_integrity_available_method_name &&
|
||||
get_integrity_token_method_name && get_support_key &&
|
||||
get_token_key && get_result_key && set_request_handler_raw &&
|
||||
clear_request_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxAccountProtocolObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_request_handler)(void*, std::shared_ptr<void>,
|
||||
std::string (*)(void*, JNIEnv*,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_request_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_request_handler &&
|
||||
clear_request_handler;
|
||||
}
|
||||
};
|
||||
|
||||
// Answers AccountProtocol device-integrity requests for a host that cannot
|
||||
// produce Play Integrity tokens. No integrity token is ever produced.
|
||||
//
|
||||
// DeviceIntegrityAvailable reports no support. The APK handler always reports
|
||||
// support, so this answer differs from Android: it tells Roblox's challenge
|
||||
// flow up front that no token can come, instead of letting it request one.
|
||||
//
|
||||
// GetIntegrityToken returns at once with an empty token and
|
||||
// TOKEN_PROVIDER_UNINITIALIZED, the APK's answer when its Play token provider
|
||||
// never initialized. The APK answers INVALID_NONCE for a request without a
|
||||
// requestHash; this bridge never inspects the request, so it cannot.
|
||||
// LuaApp bounds each token request with its
|
||||
// DeviceIntegrityNativeTimeoutMilliseconds flag; an immediate answer keeps a
|
||||
// login challenge from sitting out that bound.
|
||||
class RobloxAccountBridge final {
|
||||
public:
|
||||
RobloxAccountBridge(JniEnvironmentProvider environment,
|
||||
RobloxAccountProtocolSymbols symbols,
|
||||
RobloxAccountProtocolObjects objects);
|
||||
~RobloxAccountBridge();
|
||||
RobloxAccountBridge(const RobloxAccountBridge&) = delete;
|
||||
RobloxAccountBridge& operator=(const RobloxAccountBridge&) = delete;
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxAccountProtocolSymbols symbols_;
|
||||
const RobloxAccountProtocolObjects objects_;
|
||||
// Serializes lifecycle only. Native dispatch never holds this mutex.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
@@ -17,6 +18,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_account_bridge.h"
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
#include "runtime/webview_roblox_cookie.h"
|
||||
@@ -120,7 +122,8 @@ class RobloxExperienceComposition final {
|
||||
RobloxExperienceSurfaceProvider surface_provider = {},
|
||||
RobloxExperiencePresenceObserver presence_observer = {},
|
||||
bool clear_persisted_web_view_cookie = false,
|
||||
bool microphone_enabled = false);
|
||||
bool microphone_enabled = false,
|
||||
RobloxAccountProtocolSymbols account_symbols = {});
|
||||
~RobloxExperienceComposition();
|
||||
|
||||
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
|
||||
@@ -162,6 +165,7 @@ class RobloxExperienceComposition final {
|
||||
bool visible = true;
|
||||
bool back_navigation_disabled = false;
|
||||
bool show_domain_as_title = false;
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
static Status DispatchLaunch(void* context,
|
||||
@@ -201,8 +205,11 @@ class RobloxExperienceComposition final {
|
||||
const WebSurfacePresentation& presentation);
|
||||
// WebViewProtocol and BrowserService are two logical transports over the
|
||||
// APK's single physical WebView container. Either protocol may close or
|
||||
// mutate the surface that the other one opened.
|
||||
Status CloseWebSurface();
|
||||
// mutate the surface that the other one opened. A nonzero
|
||||
// expected_logical_generation closes only that surface.
|
||||
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
|
||||
// Closes a solved challenge that Roblox left open past its grace period.
|
||||
Status CloseUnclaimedSolvedChallenge();
|
||||
void HandleWebSurfaceExit(uint64_t process_generation);
|
||||
Status Dispatch(const RobloxExperienceLaunchRequest& request);
|
||||
Status PromoteAuthenticatedSession();
|
||||
@@ -229,6 +236,7 @@ class RobloxExperienceComposition final {
|
||||
const RobloxWebViewMessageBusSymbols web_view_symbols_;
|
||||
const RobloxBrowserServiceSymbols browser_service_symbols_;
|
||||
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
|
||||
const RobloxAccountProtocolSymbols account_symbols_;
|
||||
const bool microphone_enabled_;
|
||||
const RobloxGameSessionSymbols game_symbols_;
|
||||
const RobloxExperienceJniFactory jni_factory_;
|
||||
@@ -248,6 +256,8 @@ class RobloxExperienceComposition final {
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
|
||||
std::shared_ptr<LifecycleTarget> lifecycle_target_;
|
||||
@@ -257,6 +267,13 @@ class RobloxExperienceComposition final {
|
||||
uint64_t web_surface_logical_generation_ = 0;
|
||||
uint64_t next_web_surface_process_generation_ = 1;
|
||||
uint64_t next_web_surface_logical_generation_ = 1;
|
||||
// The current surface closes as soon as its solved challenge is forwarded.
|
||||
bool web_surface_close_when_challenge_solved_ = false;
|
||||
// Set when a solved challenge is forwarded; applies only to that logical
|
||||
// surface generation.
|
||||
std::optional<std::chrono::steady_clock::time_point>
|
||||
solved_challenge_close_deadline_;
|
||||
uint64_t solved_challenge_logical_generation_ = 0;
|
||||
SecureWebViewRobloxCookie web_view_cookie_;
|
||||
std::string web_view_cookie_initialization_error_;
|
||||
bool web_view_cookie_synchronized_ = false;
|
||||
|
||||
@@ -40,9 +40,10 @@ struct AndroidKeyMapping {
|
||||
// through with an ABI-incompatible numeric value.
|
||||
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
|
||||
|
||||
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
|
||||
// numbers are not numerically compatible with Android button bit values.
|
||||
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
|
||||
// Translates SDL button numbers to the nativePassMouseButton argument.
|
||||
// The first three are Roblox's zero-based MouseButton1/2/3 indices, not Android
|
||||
// MotionEvent button bits.
|
||||
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button);
|
||||
|
||||
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
|
||||
int32_t RobloxGamepadType(platform::GamepadFamily family);
|
||||
|
||||
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
|
||||
Status ParseRobloxLaunchUri(std::string_view uri,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
// Converts a WebView launchGame command
|
||||
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
|
||||
// contract. Returns kUnsupported for other commands and kInvalidArgument for
|
||||
// a malformed launchGame request.
|
||||
Status ParseRobloxWebViewLaunchGame(std::string_view command,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
@@ -15,6 +16,8 @@ struct RobloxPlatformWebSymbols {
|
||||
RobloxWebViewMessageBusSymbols web_view;
|
||||
RobloxBrowserServiceSymbols browser_service;
|
||||
RobloxPermissionsMessageBusSymbols permissions;
|
||||
// Optional and excluded from complete(); see RobloxExperienceComposition.
|
||||
RobloxAccountProtocolSymbols account;
|
||||
|
||||
bool complete() const {
|
||||
return web_view.complete() && browser_service.complete() &&
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_text_display_state.h"
|
||||
|
||||
struct TTF_Font;
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
@@ -39,6 +41,8 @@ class RobloxTextSurfaceOverlay final {
|
||||
const RobloxTextDisplayUpdate& update);
|
||||
void ApplyUpdate(const RobloxTextDisplayUpdate& update);
|
||||
Status RasterizeLocked();
|
||||
Status OpenFontsLocked(std::int32_t font, float point_size);
|
||||
void CloseFontsLocked();
|
||||
void ClearFrameLocked();
|
||||
void RecordFailureLocked(Status status);
|
||||
|
||||
@@ -46,10 +50,14 @@ class RobloxTextSurfaceOverlay final {
|
||||
RobloxTextDisplayState state_;
|
||||
RobloxTextOverlayViewport viewport_;
|
||||
std::vector<std::uint8_t> rgba_;
|
||||
std::vector<TTF_Font*> fonts_;
|
||||
std::int32_t fonts_font_ = 0;
|
||||
float fonts_point_size_ = 0.0F;
|
||||
std::uint64_t state_revision_ = 0;
|
||||
std::uint64_t raster_revision_ = 0;
|
||||
Status failure_;
|
||||
bool initialized_ = false;
|
||||
bool ttf_initialized_ = false;
|
||||
bool failure_logged_ = false;
|
||||
bool ready_logged_ = false;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
@@ -42,6 +44,10 @@ struct RobloxWebViewOpenRequest {
|
||||
std::optional<bool> show_domain_as_title;
|
||||
std::optional<bool> back_button_visible;
|
||||
std::optional<bool> hide_header;
|
||||
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
|
||||
// window once its solved captcha has been forwarded, as Android's captcha
|
||||
// activity finishes itself on success.
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
|
||||
@@ -61,6 +67,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
|
||||
RobloxWebViewMutationRequest* request);
|
||||
Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
// True for a page's Navigation.navigateToFeature command reporting a solved
|
||||
// verification (see webview::IsRobloxChallengeSolvedFeature).
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
|
||||
@@ -108,6 +117,33 @@ using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
||||
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
||||
using UpdateRobloxCookieSetHandlerFn =
|
||||
void (*)(JNIEnv*, jobject, jobject);
|
||||
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
||||
// and its handleJavascriptCallback subscriber, exist only while a handle is
|
||||
// held. Roblox's handle type is not trivially destructible, so its getter
|
||||
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
|
||||
// AArch64). The user-provided destructor gives this type the same calling
|
||||
// convention. It does not release the handle.
|
||||
class RobloxWebViewProtocolHandle final {
|
||||
public:
|
||||
RobloxWebViewProtocolHandle() = default;
|
||||
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
|
||||
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
|
||||
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
|
||||
default;
|
||||
~RobloxWebViewProtocolHandle() {}
|
||||
|
||||
void* holder() const { return holder_; }
|
||||
|
||||
private:
|
||||
void* holder_ = nullptr;
|
||||
};
|
||||
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
|
||||
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
|
||||
// resolver finds it through the first call in
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol.
|
||||
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
|
||||
|
||||
struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
|
||||
@@ -129,6 +165,8 @@ struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
|
||||
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
|
||||
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
||||
// Optional: found by code contract, not by symbol name.
|
||||
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_open_window_id != nullptr &&
|
||||
@@ -287,6 +325,10 @@ private:
|
||||
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
|
||||
raw_callback_targets_{};
|
||||
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
|
||||
// The reference this counts is kept for the process lifetime, as Roblox's
|
||||
// Android app start keeps its own. Neither Shutdown nor destruction
|
||||
// releases it.
|
||||
RobloxWebViewProtocolHandle web_view_protocol_handle_;
|
||||
std::size_t in_flight_dispatches_ = 0;
|
||||
bool initializing_ = false;
|
||||
bool shutting_down_ = false;
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ActivePayloadPaths {
|
||||
std::filesystem::path root;
|
||||
std::filesystem::path roblox_library;
|
||||
std::filesystem::path base_apk;
|
||||
std::filesystem::path x86_64_split_apk;
|
||||
std::filesystem::path arch_split_apk;
|
||||
std::filesystem::path assets_content;
|
||||
std::filesystem::path compatibility_manifest;
|
||||
std::filesystem::path host_abi_profile;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::webview {
|
||||
|
||||
// Navigation.navigateToFeature features a Roblox verification page sends when
|
||||
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
|
||||
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
|
||||
// helper and the runtime both classify page events with this list.
|
||||
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
|
||||
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
|
||||
feature == "ChallengeCompleted";
|
||||
}
|
||||
|
||||
} // namespace mocktail::webview
|
||||
|
||||
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
@@ -0,0 +1,10 @@
|
||||
# Allowed reduced aarch64 packages from pkgforge-dev/archlinux-pkgs-debloated.
|
||||
# The installer verifies the rolling release asset IDs and SHA256 digests.
|
||||
ffmpeg-mini-aarch64.pkg.tar.zst
|
||||
gdk-pixbuf2-mini-aarch64.pkg.tar.xz
|
||||
gtk4-mini-aarch64.pkg.tar.zst
|
||||
icu-mini-aarch64.pkg.tar.zst
|
||||
librsvg-mini-aarch64.pkg.tar.zst
|
||||
libxml2-mini-aarch64.pkg.tar.zst
|
||||
mesa-mini-aarch64.pkg.tar.zst
|
||||
opus-mini-aarch64.pkg.tar.zst
|
||||
@@ -1,12 +1,14 @@
|
||||
# Maintainer: komaruworld <rtysus228@gmail.com>
|
||||
# Maintainer: vii <coderdayton14@gmail.com>
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=1.0.3
|
||||
pkgrel=2
|
||||
pkgname=nightcap
|
||||
pkgver=0.3.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail')
|
||||
depends=(
|
||||
'capstone'
|
||||
'curl'
|
||||
@@ -16,6 +18,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
'minizip'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgbase = mocktail-bin
|
||||
pkgbase = nightcap-bin
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
||||
pkgver = 1.0.4
|
||||
pkgver = 0.3.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
depends = capstone
|
||||
@@ -17,6 +17,7 @@ pkgbase = mocktail-bin
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
@@ -29,12 +30,14 @@ pkgbase = mocktail-bin
|
||||
depends = zlib
|
||||
optdepends = gamemode: Feral GameMode integration
|
||||
provides = mocktail
|
||||
provides = nightcap
|
||||
conflicts = mocktail
|
||||
conflicts = mocktail-git
|
||||
noextract = mocktail-1.0.4-x86_64.pkg.tar.zst
|
||||
conflicts = nightcap
|
||||
conflicts = nightcap-git
|
||||
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
|
||||
options = !debug
|
||||
options = !strip
|
||||
source = mocktail-1.0.4-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.4/mocktail-1.0.4-x86_64.pkg.tar.zst
|
||||
sha256sums = d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7
|
||||
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
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail-bin
|
||||
pkgname = nightcap-bin
|
||||
@@ -1,11 +1,11 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-bin
|
||||
pkgver=1.0.4
|
||||
pkgname=nightcap-bin
|
||||
pkgver=0.3.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'capstone'
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
@@ -32,17 +33,18 @@ depends=(
|
||||
'zlib'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail' 'mocktail-git')
|
||||
provides=('mocktail' 'nightcap')
|
||||
conflicts=('mocktail' 'nightcap' 'nightcap-git')
|
||||
options=('!debug' '!strip')
|
||||
# Release asset names do not include the AUR package release.
|
||||
_archive="mocktail-${pkgver}-${CARCH}.pkg.tar.zst"
|
||||
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
|
||||
# The release asset carries the pacman package release built by release.yml.
|
||||
_archive="nightcap-${pkgver}-1-${CARCH}.pkg.tar.zst"
|
||||
source=("${_archive}::https://github.com/CoderDayton/nightcap/releases/download/v${pkgver}/${_archive}")
|
||||
noextract=("${_archive}")
|
||||
sha256sums=('d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7')
|
||||
# Replace with the checksum published beside the release asset.
|
||||
sha256sums=('SKIP')
|
||||
|
||||
package() {
|
||||
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
|
||||
install -Dm644 "${pkgdir}/usr/share/licenses/mocktail/LICENSE" \
|
||||
install -Dm644 "${pkgdir}/usr/share/licenses/nightcap/LICENSE" \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgbase = mocktail
|
||||
pkgbase = nightcap-git
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 1.0.4
|
||||
pkgver = 0.3.1.r0.g0000000
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
makedepends = cmake>=3.20
|
||||
@@ -24,6 +24,7 @@ pkgbase = mocktail
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
@@ -35,11 +36,13 @@ pkgbase = mocktail
|
||||
depends = webkitgtk-6.0
|
||||
depends = zlib
|
||||
optdepends = gamemode: Feral GameMode integration
|
||||
conflicts = mocktail-bin
|
||||
conflicts = mocktail-git
|
||||
provides = mocktail
|
||||
provides = nightcap
|
||||
conflicts = mocktail
|
||||
conflicts = nightcap
|
||||
conflicts = nightcap-bin
|
||||
options = !debug
|
||||
options = !lto
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.4
|
||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail
|
||||
pkgname = nightcap-git
|
||||
@@ -1,11 +1,11 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-git
|
||||
pkgver=1.0.2.r24.g9bdd737
|
||||
pkgname=nightcap-git
|
||||
pkgver=0.3.1.r0.g0000000
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'capstone'
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
@@ -41,31 +42,30 @@ makedepends=(
|
||||
'vulkan-headers'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail' 'mocktail-bin')
|
||||
provides=('mocktail' 'nightcap')
|
||||
conflicts=('mocktail' 'nightcap' 'nightcap-bin')
|
||||
options=('!debug')
|
||||
source=(
|
||||
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
|
||||
'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
pkgver() {
|
||||
cd mocktail
|
||||
cd nightcap
|
||||
|
||||
local version
|
||||
version="$(sed -n \
|
||||
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
|
||||
CMakeLists.txt | head -n 1)"
|
||||
version="$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null)"
|
||||
version="${version#v}"
|
||||
printf '%s.r%s.g%s' \
|
||||
"${version}" \
|
||||
"${version:-0.0.0}" \
|
||||
"$(git rev-list --count HEAD)" \
|
||||
"$(git rev-parse --short=7 HEAD)"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
cd mocktail
|
||||
cd nightcap
|
||||
|
||||
sed -i \
|
||||
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
|
||||
@@ -73,7 +73,7 @@ prepare() {
|
||||
}
|
||||
|
||||
build() {
|
||||
cmake -S mocktail -B build -G Ninja \
|
||||
cmake -S nightcap -B build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
@@ -86,6 +86,6 @@ build() {
|
||||
|
||||
package() {
|
||||
DESTDIR="${pkgdir}" cmake --install build
|
||||
install -Dm644 mocktail/LICENSE \
|
||||
install -Dm644 nightcap/LICENSE \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgbase = mocktail-git
|
||||
pkgbase = nightcap
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 1.0.2.r24.g9bdd737
|
||||
pkgver = 0.3.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
url = https://github.com/CoderDayton/nightcap
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
makedepends = cmake>=3.20
|
||||
@@ -24,6 +24,7 @@ pkgbase = mocktail-git
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
@@ -37,9 +38,11 @@ pkgbase = mocktail-git
|
||||
optdepends = gamemode: Feral GameMode integration
|
||||
provides = mocktail
|
||||
conflicts = mocktail
|
||||
conflicts = mocktail-bin
|
||||
conflicts = nightcap-bin
|
||||
conflicts = nightcap-git
|
||||
options = !debug
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main
|
||||
options = !lto
|
||||
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail-git
|
||||
pkgname = nightcap
|
||||
@@ -1,11 +1,11 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
# Maintainer: vii <coderdayton14 at gmail dot com>
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=1.0.4
|
||||
pkgname=nightcap
|
||||
pkgver=0.3.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
url='https://github.com/CoderDayton/nightcap'
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'capstone'
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
@@ -41,17 +42,18 @@ makedepends=(
|
||||
'vulkan-headers'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
conflicts=('mocktail-bin' 'mocktail-git')
|
||||
provides=('mocktail')
|
||||
conflicts=('mocktail' 'nightcap-bin' 'nightcap-git')
|
||||
options=('!debug' '!lto')
|
||||
source=(
|
||||
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
|
||||
"nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v${pkgver}"
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd mocktail
|
||||
cd nightcap
|
||||
|
||||
sed -i \
|
||||
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
|
||||
@@ -59,7 +61,7 @@ prepare() {
|
||||
}
|
||||
|
||||
build() {
|
||||
cmake -S mocktail -B build -G Ninja \
|
||||
cmake -S nightcap -B build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
@@ -72,6 +74,6 @@ build() {
|
||||
|
||||
package() {
|
||||
DESTDIR="${pkgdir}" cmake --install build
|
||||
install -Dm644 mocktail/LICENSE \
|
||||
install -Dm644 nightcap/LICENSE \
|
||||
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -21,8 +21,9 @@
|
||||
Sign in to Roblox, browse experiences, and play them on Linux.
|
||||
</p>
|
||||
<p>
|
||||
Mocktail is an independent community project. It is not affiliated with
|
||||
Roblox Corporation or VinegarHQ, and the Roblox client is not included.
|
||||
Nightcap is an independent community project, forked from Mocktail. It
|
||||
is not affiliated with Roblox Corporation or VinegarHQ, and the Roblox
|
||||
client is not included.
|
||||
</p>
|
||||
</description>
|
||||
<screenshots>
|
||||
@@ -56,45 +57,61 @@
|
||||
<binary>mocktail</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="1.0.4" date="2026-09-11">
|
||||
<release version="0.3.2" date="2026-09-29">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed login issues</li>
|
||||
<li>Fixed low-resolution textures, skybox rendering and HiDPI scaling.</li>
|
||||
<li>Added vc support.</li>
|
||||
<li>Added Xbox and PlayStation gamepad support.</li>
|
||||
<li>Added Nix support</li>
|
||||
<li>Improved text input, fullscreen mode, window sizing, and mouse controls.</li>
|
||||
<li>Updated support for Roblox 2.736.1408.</li>
|
||||
<li>Added experimental support for ARM64 Linux.</li>
|
||||
<li>Fixed login stalling after passing a verification check.</li>
|
||||
<li>Fixed login check windows staying open after they were solved.</li>
|
||||
<li>Fixed Play on a private server doing nothing until a sidebar tab was clicked.</li>
|
||||
<li>Less stutter while joining a game and while textures load in.</li>
|
||||
<li>Middle click and natural scrolling now work as expected.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.3" date="2026-08-16">
|
||||
<release version="0.3.1" date="2026-09-20">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed issues with downloading Roblox on first launch</li>
|
||||
<li>Added fflags.json for custom FFlags</li>
|
||||
<li>Added a message if your cookies have expired</li>
|
||||
<li>Added DEB, RPM, and pacman packages to every release.</li>
|
||||
<li>Smoother frames: textures no longer decode on the thread that submits the frame.</li>
|
||||
<li>Fixed the mouse pointer jumping when a right-click camera drag ended.</li>
|
||||
<li>Fixed the text caret not moving when a trailing space was typed.</li>
|
||||
<li>Faster typing in text boxes.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.2" date="2026-08-14">
|
||||
<release version="0.3.0" date="2026-09-18">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed the “Roblox is out of date” bug</li>
|
||||
<li>Improved logging</li>
|
||||
<li>Fixed issues with Discord RPC where the game you were playing wasn’t displayed</li>
|
||||
<li>Added a custom Discord presence: set the title, state, and icons.</li>
|
||||
<li>Added --profile to record a trace of the renderer's work.</li>
|
||||
<li>Faster texture loading: compressed textures decode and resample on worker threads.</li>
|
||||
<li>Lower CPU use while playing.</li>
|
||||
<li>Fixed GameMode pinning the game to a few CPU cores.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.1" date="2026-08-13">
|
||||
<release version="0.2.0" date="2026-09-16">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fix issues with GameMode</li>
|
||||
<li>Add Discord RPC support</li>
|
||||
<li>Discord Rich Presence is on by default.</li>
|
||||
<li>Added input.raw_mouse for mouse look in the host's own units.</li>
|
||||
<li>Fixed mouse look after a long turn.</li>
|
||||
<li>Fixed a first-run failure with "cannot lock appStorage".</li>
|
||||
<li>Values in fflags.json now win over the performance preset.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0" date="2026-09-15">
|
||||
<description>
|
||||
<ul>
|
||||
<li>First Nightcap release, forked from Mocktail.</li>
|
||||
<li>Added ETC2 and EAC texture emulation on Vulkan.</li>
|
||||
<li>Added PNG texture overrides.</li>
|
||||
<li>Lower input latency.</li>
|
||||
<li>Roblox now sees the real host RAM and display size.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0" date="2026-08-12"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -23,6 +23,7 @@
|
||||
elfutils,
|
||||
libglvnd,
|
||||
libplacebo,
|
||||
libpng,
|
||||
libsoup_3,
|
||||
libutf8proc,
|
||||
libyaml,
|
||||
@@ -92,6 +93,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
elfutils
|
||||
libglvnd
|
||||
libplacebo
|
||||
libpng
|
||||
libsoup_3
|
||||
libutf8proc
|
||||
libyaml
|
||||
|
||||
@@ -461,6 +461,7 @@ LatestSupportedBootstrapProfile() {
|
||||
.version_code <= ($remote_version_code | tonumber)) and
|
||||
.status == "supported" and .default_allowed == true and
|
||||
.allow_legacy_binary_patches == false and
|
||||
(.abi // "x86_64") == "x86_64" and
|
||||
(.version_name | type) == "string" and
|
||||
(.version_name | length) > 0)] |
|
||||
group_by(.version_code) |
|
||||
@@ -495,6 +496,7 @@ SelectSupportedBootstrapFallback() {
|
||||
bootstrap_source="$(jq -er --argjson version_code "${fallback_version_code}" '
|
||||
[.sources[]? |
|
||||
select(.version_code == $version_code and
|
||||
(.abi // "x86_64") == "x86_64" and
|
||||
.provider == "uptodown")] |
|
||||
if length == 1 then .[0].provider else empty end
|
||||
' "${BOOTSTRAP_SOURCES_PATH}" 2>/dev/null || true)"
|
||||
|
||||
@@ -329,6 +329,7 @@ def run_trial(args, max_ctors, skipped_offsets, trial_id):
|
||||
log_path = args.log_dir / f"trial_{trial_id:03d}_max_{max_ctors}.log"
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"ROBLOX_LIB_PATH": str(args.libroblox),
|
||||
"MOCKTAIL_RUN_LIBROBLOX_CTORS": "1",
|
||||
"MOCKTAIL_LIBROBLOX_CTOR_POLICY": args.ctor_policy,
|
||||
"MOCKTAIL_GRAPHICS_BACKEND": args.graphics_backend,
|
||||
@@ -500,7 +501,7 @@ def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project-root", type=Path, default=project_root)
|
||||
parser.add_argument("--run-script", type=Path,
|
||||
default=project_root / "scripts" / "run_sober.sh")
|
||||
default=project_root / "build" / "mocktail")
|
||||
parser.add_argument("--libroblox", type=Path,
|
||||
default=project_root / "rbx_bin" / "libroblox.so")
|
||||
parser.add_argument("--log-dir", type=Path,
|
||||
|
||||
@@ -10,18 +10,18 @@ readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
|
||||
|
||||
BUILD_DIR="${PROJECT_ROOT}/build-flatpak"
|
||||
MANIFEST="${PROJECT_ROOT}/packaging/flatpak/io.github.CoderDayton.nightcap.json"
|
||||
JOBS="${MOCKTAIL_FLATPAK_JOBS:-4}"
|
||||
JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"
|
||||
|
||||
Usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/build_flatpak.sh [OPTIONS]
|
||||
|
||||
Build and install Mocktail as a per-user x86-64 Flatpak.
|
||||
Build and install Mocktail as a per-user Flatpak for the host architecture.
|
||||
|
||||
Options:
|
||||
--build-dir DIR Builder output directory (default: build-flatpak).
|
||||
--manifest FILE Flatpak manifest path.
|
||||
--jobs N Maximum parallel build jobs (default: 4).
|
||||
--jobs N Maximum parallel build jobs (default: 2).
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
@@ -93,8 +93,11 @@ done
|
||||
[[ "${JOBS}" =~ ^[1-9][0-9]*$ && "${JOBS}" -le 256 ]] ||
|
||||
Die "--jobs must be an integer between 1 and 256"
|
||||
|
||||
[[ "$(uname -m)" == x86_64 ]] ||
|
||||
Die "the Roblox payload and Flatpak manifest currently require x86-64"
|
||||
HOST_ARCH="$(uname -m)"
|
||||
case "${HOST_ARCH}" in
|
||||
x86_64|aarch64) ;;
|
||||
*) Die "Flatpak builds require an x86_64 or aarch64 Linux host" ;;
|
||||
esac
|
||||
command -v flatpak >/dev/null 2>&1 || Die "flatpak is required"
|
||||
|
||||
MANIFEST="$(realpath -e -- "${MANIFEST}")" ||
|
||||
@@ -123,7 +126,7 @@ CleanupStaleBuilderMounts
|
||||
|
||||
cd -- "${PROJECT_ROOT}"
|
||||
exec "${builder[@]}" \
|
||||
--arch=x86_64 \
|
||||
--arch="${HOST_ARCH}" \
|
||||
--jobs="${JOBS}" \
|
||||
--force-clean \
|
||||
--install-deps-from=flathub \
|
||||
|
||||
@@ -61,6 +61,7 @@ def supported_profiles(catalog: dict) -> list[dict]:
|
||||
if isinstance(profile, dict)
|
||||
and profile.get("status") == "supported"
|
||||
and profile.get("default_allowed") is True
|
||||
and profile.get("abi", ARCHITECTURE) == ARCHITECTURE
|
||||
and isinstance(profile.get("version_name"), str)
|
||||
and isinstance(profile.get("version_code"), int)
|
||||
]
|
||||
|
||||
@@ -18,12 +18,15 @@ AnyLinuxDependencyRequireContainer() {
|
||||
}
|
||||
|
||||
AnyLinuxDependencySnapshot() {
|
||||
local manifest="$1" release="$2" filename remainder names_json
|
||||
local manifest="$1" release="$2" host_arch="${3:-$(uname -m)}"
|
||||
local filename remainder names_json
|
||||
[[ "${host_arch}" == x86_64 || "${host_arch}" == aarch64 ]] ||
|
||||
AnyLinuxDependencyDie 'unsupported AppImage architecture'
|
||||
local -a names=()
|
||||
local -A seen=()
|
||||
while read -r filename remainder || [[ -n "${filename}" ]]; do
|
||||
[[ -n "${filename}" && "${filename}" != \#* ]] || continue
|
||||
[[ "${filename}" =~ ^[a-z0-9-]+-mini-x86_64\.pkg\.tar\.zst$ &&
|
||||
[[ "${filename}" =~ ^[a-z0-9-]+-mini-${host_arch}\.pkg\.tar\.(zst|xz)$ &&
|
||||
-z "${remainder}" && -z "${seen[${filename}]:-}" ]] ||
|
||||
AnyLinuxDependencyDie 'invalid or duplicate dependency manifest entry'
|
||||
seen["${filename}"]=1
|
||||
@@ -108,7 +111,13 @@ AnyLinuxDependenciesMain() (
|
||||
AnyLinuxDependencyRequireContainer
|
||||
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
||||
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.txt"
|
||||
local host_arch="$(uname -m)" manifest_name
|
||||
case "${host_arch}" in
|
||||
x86_64) manifest_name=anylinux-dependencies.txt ;;
|
||||
aarch64) manifest_name=anylinux-dependencies-aarch64.txt ;;
|
||||
*) AnyLinuxDependencyDie "unsupported AppImage architecture: ${host_arch}" ;;
|
||||
esac
|
||||
readonly MANIFEST="${ROOT}/packaging/${manifest_name}"
|
||||
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
|
||||
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
|
||||
AnyLinuxDownloadDependencies "${MANIFEST}" "${DOWNLOADS}"
|
||||
|
||||
@@ -119,6 +119,7 @@ libyaml
|
||||
fontconfig
|
||||
minizip
|
||||
libplacebo
|
||||
libpng
|
||||
util-linux'
|
||||
INSTALL_ARGS='-S --needed --noconfirm'
|
||||
;;
|
||||
@@ -144,6 +145,7 @@ libyaml
|
||||
fontconfig
|
||||
minizip
|
||||
libplacebo
|
||||
libpng
|
||||
util-linux'
|
||||
if HostUsesMusl; then
|
||||
PACKAGES="$PACKAGES gcompat"
|
||||
@@ -172,6 +174,7 @@ libyaml
|
||||
fontconfig
|
||||
minizip
|
||||
libplacebo
|
||||
libpng
|
||||
coreutils
|
||||
findutils
|
||||
util-linux'
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# Prints the native package version for the current ref. A release tag gives
|
||||
# its own version; anything else gives 0.0.0, which sorts below every release.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
ref="${1:-${GITHUB_REF_NAME:-}}"
|
||||
version="${ref#v}"
|
||||
|
||||
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
printf '%s\n' "${version}"
|
||||
else
|
||||
printf '0.0.0\n'
|
||||
fi
|
||||
@@ -110,8 +110,14 @@ for elf in "$@"; do
|
||||
fi
|
||||
machine="$(LC_ALL=C readelf --file-header "${elf}" 2>/dev/null |
|
||||
sed -n 's/^[[:space:]]*Machine:[[:space:]]*//p')"
|
||||
if [[ "${machine}" != *X86-64* ]]; then
|
||||
printf 'ABI check: %s is not x86-64 (%s)\n' "${elf}" "${machine}" >&2
|
||||
case "$(uname -m)" in
|
||||
x86_64) expected_machine='X86-64' ;;
|
||||
aarch64) expected_machine='AArch64' ;;
|
||||
*) expected_machine='X86-64' ;;
|
||||
esac
|
||||
if [[ "${machine}" != *"${expected_machine}"* ]]; then
|
||||
printf 'ABI check: %s is not %s (%s)\n' "${elf}" "${expected_machine}" \
|
||||
"${machine}" >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<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
|
||||
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
|
||||
cd nightcap && make build
|
||||
@@ -40,6 +44,7 @@ cd nightcap && make build
|
||||
<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>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "compat/bionic_pthread_create_runtime.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "compat/http_client_spin_guard.h"
|
||||
#include "window/window.h"
|
||||
|
||||
@@ -31,6 +33,14 @@ const char* EnvironmentOr(const char* name, const char* fallback) {
|
||||
return value != nullptr && value[0] != '\0' ? value : fallback;
|
||||
}
|
||||
|
||||
const char* GuestAbiPropertyValue() {
|
||||
#if defined(__aarch64__)
|
||||
return "arm64-v8a";
|
||||
#else
|
||||
return "x86_64";
|
||||
#endif
|
||||
}
|
||||
|
||||
int CallHostVsnprintf(char* dst, size_t count, const char* format,
|
||||
va_list args) {
|
||||
using HostVsnprintf = int (*)(char*, size_t, const char*, va_list);
|
||||
@@ -173,8 +183,12 @@ int __system_property_get(const char* name, char* value) {
|
||||
result = "35";
|
||||
} else if (std::strcmp(name, "ro.build.version.release") == 0) {
|
||||
result = "15";
|
||||
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0) {
|
||||
result = "x86_64";
|
||||
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0 ||
|
||||
std::strcmp(name, "ro.product.cpu.abilist") == 0 ||
|
||||
std::strcmp(name, "ro.product.cpu.abilist64") == 0) {
|
||||
result = GuestAbiPropertyValue();
|
||||
} else if (std::strcmp(name, "ro.product.cpu.abilist32") == 0) {
|
||||
result = "";
|
||||
} else if (std::strcmp(name, "ro.product.manufacturer") == 0) {
|
||||
result = EnvironmentOr("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
|
||||
} else if (std::strcmp(name, "ro.product.model") == 0) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
#include "compat/bionic_pthread_create_runtime.h"
|
||||
|
||||
#include "compat/bionic_abi_exports.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
|
||||
@@ -69,24 +73,28 @@ size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
|
||||
return guest_stack_size;
|
||||
}
|
||||
|
||||
int CopySupportedThreadAttributes(const pthread_attr_t& source,
|
||||
// Guest pthread_attr_t buffers use the Bionic LP64 layout; they are never a
|
||||
// host pthread_attr_t, so decode the fixed fields directly.
|
||||
int GuestDetachState(const MocktailBionicPthreadAttr& source) noexcept {
|
||||
return (source.flags & kMocktailBionicAttrFlagDetached) != 0
|
||||
? PTHREAD_CREATE_DETACHED
|
||||
: PTHREAD_CREATE_JOINABLE;
|
||||
}
|
||||
|
||||
size_t GuestStackSize(const MocktailBionicPthreadAttr& source) noexcept {
|
||||
return source.stack_size != 0 ? source.stack_size
|
||||
: kBionicLp64DefaultThreadStackSize;
|
||||
}
|
||||
|
||||
int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
int result = pthread_attr_getdetachstate(&source, &detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setdetachstate(destination, detach_state);
|
||||
int result =
|
||||
pthread_attr_setdetachstate(destination, GuestDetachState(source));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
size_t guard_size = 0;
|
||||
result = pthread_attr_getguardsize(&source, &guard_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setguardsize(destination, guard_size);
|
||||
result = pthread_attr_setguardsize(destination, source.guard_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -95,53 +103,38 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
|
||||
// pthread_attr_setstack. Copying pthread_attr_getstack's address is unsafe:
|
||||
// glibc represents an automatically allocated stack with a synthetic
|
||||
// address derived from its size.
|
||||
size_t stack_size = 0;
|
||||
result = pthread_attr_getstacksize(&source, &stack_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setstacksize(destination,
|
||||
HostStackSizeForGuest(stack_size));
|
||||
result = pthread_attr_setstacksize(
|
||||
destination, HostStackSizeForGuest(GuestStackSize(source)));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int inherit_scheduler = PTHREAD_INHERIT_SCHED;
|
||||
result = pthread_attr_getinheritsched(&source, &inherit_scheduler);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
// Bionic applies the attribute's scheduling policy at creation whenever it
|
||||
// differs from the normal policy.
|
||||
if (RejectRealtimeScheduling(source.sched_policy)) {
|
||||
return EPERM;
|
||||
}
|
||||
if (inherit_scheduler == PTHREAD_EXPLICIT_SCHED) {
|
||||
int scheduler_policy = 0;
|
||||
sched_param scheduler_parameters{};
|
||||
result = pthread_attr_getschedpolicy(&source, &scheduler_policy);
|
||||
if (result == 0) {
|
||||
result = pthread_attr_getschedparam(&source, &scheduler_parameters);
|
||||
}
|
||||
if (result == 0 && RejectRealtimeScheduling(scheduler_policy)) {
|
||||
return EPERM;
|
||||
}
|
||||
if (result == 0) {
|
||||
result = pthread_attr_setschedpolicy(destination, scheduler_policy);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
|
||||
}
|
||||
if (source.sched_policy != SCHED_NORMAL) {
|
||||
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
sched_param scheduler_parameters{};
|
||||
scheduler_parameters.sched_priority = source.sched_priority;
|
||||
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
return pthread_attr_setinheritsched(destination, PTHREAD_EXPLICIT_SCHED);
|
||||
}
|
||||
return pthread_attr_setinheritsched(destination, inherit_scheduler);
|
||||
|
||||
return pthread_attr_setinheritsched(destination, PTHREAD_INHERIT_SCHED);
|
||||
}
|
||||
|
||||
int CopyRequiredThreadAttributes(const pthread_attr_t& source,
|
||||
int CopyRequiredThreadAttributes(const MocktailBionicPthreadAttr& source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
int result = pthread_attr_getdetachstate(&source, &detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setdetachstate(destination, detach_state);
|
||||
int result =
|
||||
pthread_attr_setdetachstate(destination, GuestDetachState(source));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -149,32 +142,20 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source,
|
||||
// The retry must retain an explicitly requested guest stack. Falling back
|
||||
// to a null/default host attr is unsafe on musl, whose default stack is much
|
||||
// smaller than Bionic's and can be exhausted by a single libroblox frame.
|
||||
size_t stack_size = 0;
|
||||
result = pthread_attr_getstacksize(&source, &stack_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
return pthread_attr_setstacksize(destination,
|
||||
HostStackSizeForGuest(stack_size));
|
||||
return pthread_attr_setstacksize(
|
||||
destination, HostStackSizeForGuest(GuestStackSize(source)));
|
||||
}
|
||||
|
||||
int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
|
||||
int ConfigureHostSafeStackFallback(const MocktailBionicPthreadAttr* source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
size_t guest_stack_size = 0;
|
||||
if (source != nullptr) {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
int result = pthread_attr_getdetachstate(source, &detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setdetachstate(destination, detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_getstacksize(source, &guest_stack_size);
|
||||
int result =
|
||||
pthread_attr_setdetachstate(destination, GuestDetachState(*source));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
guest_stack_size = GuestStackSize(*source);
|
||||
}
|
||||
|
||||
size_t host_default_stack_size = 0;
|
||||
@@ -207,7 +188,8 @@ void ConfigureBionicPthreadThreadInitializer(
|
||||
g_thread_initializer.store(initializer, std::memory_order_release);
|
||||
}
|
||||
|
||||
int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
|
||||
int CreateBionicPthread(pthread_t* thread,
|
||||
const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument) {
|
||||
if (thread == nullptr || start_routine == nullptr) {
|
||||
return EINVAL;
|
||||
@@ -289,10 +271,124 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
|
||||
const pthread_attr_t* attr,
|
||||
void* (*start_routine)(void*),
|
||||
void* argument) {
|
||||
extern "C" {
|
||||
|
||||
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr) {
|
||||
if (attr == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
attr->flags = 0;
|
||||
attr->stack_base = nullptr;
|
||||
attr->stack_size =
|
||||
mocktail::compat::kBionicLp64DefaultThreadStackSize;
|
||||
const long page_size = sysconf(_SC_PAGESIZE);
|
||||
attr->guard_size = page_size > 0 ? static_cast<size_t>(page_size) : 0;
|
||||
attr->sched_policy = SCHED_OTHER;
|
||||
attr->sched_priority = 0;
|
||||
std::memset(attr->reserved, 0, sizeof(attr->reserved));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr) {
|
||||
(void)attr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
|
||||
size_t stack_size) {
|
||||
if (attr == nullptr || stack_size < PTHREAD_STACK_MIN) {
|
||||
return EINVAL;
|
||||
}
|
||||
attr->stack_size = stack_size;
|
||||
attr->flags &= ~kMocktailBionicAttrFlagUserStack;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
|
||||
int detach_state) {
|
||||
if (attr == nullptr || (detach_state != PTHREAD_CREATE_DETACHED &&
|
||||
detach_state != PTHREAD_CREATE_JOINABLE)) {
|
||||
return EINVAL;
|
||||
}
|
||||
if (detach_state == PTHREAD_CREATE_DETACHED) {
|
||||
attr->flags |= kMocktailBionicAttrFlagDetached;
|
||||
} else {
|
||||
attr->flags &= ~kMocktailBionicAttrFlagDetached;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
|
||||
const struct sched_param* parameters) {
|
||||
if (attr == nullptr || parameters == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
attr->sched_priority = parameters->sched_priority;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_getattr_np(pthread_t thread,
|
||||
MocktailBionicPthreadAttr* attr) {
|
||||
if (attr == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
pthread_attr_t host_attr;
|
||||
int result = pthread_getattr_np(thread, &host_attr);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
void* stack_base = nullptr;
|
||||
size_t stack_size = 0;
|
||||
result = pthread_attr_getstack(&host_attr, &stack_base, &stack_size);
|
||||
size_t guard_size = 0;
|
||||
if (result == 0) {
|
||||
result = pthread_attr_getguardsize(&host_attr, &guard_size);
|
||||
}
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
if (result == 0) {
|
||||
result = pthread_attr_getdetachstate(&host_attr, &detach_state);
|
||||
}
|
||||
sched_param parameters{};
|
||||
int policy = SCHED_OTHER;
|
||||
if (result == 0) {
|
||||
result = pthread_getschedparam(thread, &policy, ¶meters);
|
||||
}
|
||||
pthread_attr_destroy(&host_attr);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
attr->flags = detach_state == PTHREAD_CREATE_DETACHED
|
||||
? kMocktailBionicAttrFlagDetached
|
||||
: 0;
|
||||
attr->stack_base = stack_base;
|
||||
attr->stack_size = stack_size;
|
||||
attr->guard_size = guard_size;
|
||||
attr->sched_policy = policy;
|
||||
attr->sched_priority = parameters.sched_priority;
|
||||
std::memset(attr->reserved, 0, sizeof(attr->reserved));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
|
||||
void** stack_base, size_t* stack_size) {
|
||||
if (attr == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
if (stack_base != nullptr) {
|
||||
*stack_base = attr->stack_base;
|
||||
}
|
||||
if (stack_size != nullptr) {
|
||||
*stack_size = attr->stack_size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
} // extern "C"
|
||||
|
||||
extern "C" int mocktail_bionic_pthread_create(
|
||||
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument) {
|
||||
return mocktail::compat::CreateBionicPthread(thread, attr, start_routine,
|
||||
argument);
|
||||
}
|
||||
|
||||
@@ -39,12 +39,23 @@ bool PatchAbsoluteJump(uintptr_t address, void *target) noexcept {
|
||||
return false;
|
||||
}
|
||||
|
||||
#if defined(__aarch64__)
|
||||
// ldr x16, #8 ; br x16 ; .quad target
|
||||
// x16 is the intra-procedure-call scratch register, free to clobber.
|
||||
unsigned char patch[] = {
|
||||
0x50, 0x00, 0x00, 0x58, 0x00, 0x02, 0x1f, 0xd6,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
|
||||
std::memcpy(patch + 8, &target_address, sizeof(target_address));
|
||||
#else
|
||||
unsigned char patch[] = {
|
||||
0x48, 0xb8, // movabs rax, imm64
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xe0, // jmp rax
|
||||
};
|
||||
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
|
||||
std::memcpy(patch + 2, &target_address, sizeof(target_address));
|
||||
#endif
|
||||
|
||||
const uintptr_t page_mask = static_cast<uintptr_t>(page_size) - 1;
|
||||
const uintptr_t page = address & ~page_mask;
|
||||
|
||||
@@ -6,6 +6,34 @@ namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
constexpr HostAbiProfile kProfiles[] = {
|
||||
// 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.
|
||||
// Constructor policy mirrors x86-64 by semantic role: the pre-init
|
||||
// entries 0..3 stay skipped, index 4 is mi_process_load (thread init
|
||||
// checkpoint), index 5 is allocator wiring and index 6 is the lazy clock
|
||||
// singleton getter, both excluded under host bridges only.
|
||||
{"ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
|
||||
{{{0x1d53840, HostBridgeKind::kAllocate, "small-allocate"},
|
||||
{0x1d56c34, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x1d6ef80, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1d53568, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x61d3850, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1d57328, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0, 0, 0x7296c88, 0x400, 0x1d53c54, 0x1dc76a0,
|
||||
0x71d6c90, 0x71d2c40, 0x400000},
|
||||
{0x1d53568, 0x1d57328},
|
||||
0x6733128,
|
||||
3565,
|
||||
{{{4, 5}, {7, 3565}}},
|
||||
2,
|
||||
{{{4, 3565}}},
|
||||
1,
|
||||
4,
|
||||
{0x2132c5c, 0x71d6d30},
|
||||
HostAllocatorStrategy::kNativeMimalloc},
|
||||
// Roblox 2.736.1408. Derived from the verified 2908 profile and validated
|
||||
// by two isolated no-recovery Vulkan canaries and real game sessions.
|
||||
{"ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::compat {
|
||||
namespace {
|
||||
@@ -611,19 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
|
||||
*error = "external host ABI profile has inconsistent constructor policy";
|
||||
return nullptr;
|
||||
}
|
||||
if (owned->profile.init_array_count <= 5 ||
|
||||
// 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 != 2 ||
|
||||
owned->profile.constructor_run_ranges[0].end_exclusive != 3 ||
|
||||
owned->profile.constructor_run_ranges[1].begin != 5 ||
|
||||
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 != 2 ||
|
||||
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 ||
|
||||
owned->profile.native_mimalloc_thread_initializer_after_constructor !=
|
||||
2) {
|
||||
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
|
||||
*error =
|
||||
"external host ABI profile changed the verified constructor policy";
|
||||
return nullptr;
|
||||
@@ -669,9 +677,10 @@ bool ValidateProfileAgainstElf(const std::string& payload_path,
|
||||
}
|
||||
GElf_Ehdr header{};
|
||||
if (gelf_getehdr(elf.get(), &header) == nullptr ||
|
||||
gelf_getclass(elf.get()) != ELFCLASS64 || header.e_machine != EM_X86_64 ||
|
||||
header.e_type != ET_DYN) {
|
||||
*error = "payload must be an x86-64 ET_DYN ELF object";
|
||||
gelf_getclass(elf.get()) != ELFCLASS64 ||
|
||||
header.e_machine != kGuestElfMachine || header.e_type != ET_DYN) {
|
||||
*error = "payload must be a " + std::string(kGuestAbi) +
|
||||
" ET_DYN ELF object";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Android Vulkan loader ABI -> host Vulkan loader + SDL3 WSI.
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
@@ -11,6 +12,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -1149,16 +1151,28 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Samples the time between successive adapter present calls.
|
||||
// Samples the time between successive adapter present calls, and the CPU
|
||||
// time the presenting thread and the whole process used in it.
|
||||
void RecordProfiledFrame(mocktail::graphics::ChromeTraceWriter* trace,
|
||||
std::uint64_t present_start_ns) {
|
||||
static std::atomic<std::uint64_t> previous_start_ns{0};
|
||||
static mocktail::graphics::FrameCpuSampler cpu_sampler;
|
||||
const std::uint64_t previous =
|
||||
previous_start_ns.exchange(present_start_ns, std::memory_order_relaxed);
|
||||
if (previous != 0 && present_start_ns > previous) {
|
||||
trace->Counter("frame interval (ms)", present_start_ns,
|
||||
static_cast<double>(present_start_ns - previous) / 1e6);
|
||||
}
|
||||
mocktail::graphics::FrameCpuSample cpu;
|
||||
if (cpu_sampler.Sample(static_cast<std::uint64_t>(pthread_self()),
|
||||
mocktail::graphics::TraceThreadCpuNanos(),
|
||||
mocktail::graphics::TraceProcessCpuNanos(), &cpu)) {
|
||||
trace->Counter("process cpu (ms)", present_start_ns, cpu.process_ms);
|
||||
if (cpu.has_thread) {
|
||||
trace->Counter("present thread cpu (ms)", present_start_ns,
|
||||
cpu.thread_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline and shader creation is wrapped only while profiling, so a normal
|
||||
@@ -1240,7 +1254,32 @@ VkResult VKAPI_CALL ProfiledCreatePipelineCache(
|
||||
return result;
|
||||
}
|
||||
|
||||
VkResult VKAPI_CALL ProfiledAllocateMemory(
|
||||
VkDevice device, const VkMemoryAllocateInfo* allocate_info,
|
||||
const VkAllocationCallbacks* allocator, VkDeviceMemory* memory) {
|
||||
const auto host = reinterpret_cast<PFN_vkAllocateMemory>(
|
||||
HostDeviceProc(device, "vkAllocateMemory"));
|
||||
if (host == nullptr) {
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkAllocateMemory", "memory");
|
||||
const VkResult result = host(device, allocate_info, allocator, memory);
|
||||
scope.Arg("bytes", allocate_info != nullptr
|
||||
? static_cast<std::int64_t>(
|
||||
allocate_info->allocationSize)
|
||||
: 0);
|
||||
scope.Arg("type", allocate_info != nullptr
|
||||
? static_cast<std::int64_t>(
|
||||
allocate_info->memoryTypeIndex)
|
||||
: -1);
|
||||
scope.Arg("result", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Null unless profiling is on and `name` is a profiled device command.
|
||||
// These take precedence over the ETC2 wrappers, so a command both wrap is
|
||||
// timed inside its adapter function instead.
|
||||
PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
|
||||
if (name == nullptr || mocktail::graphics::ActiveProfileTrace() == nullptr) {
|
||||
return nullptr;
|
||||
@@ -1259,6 +1298,9 @@ PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
|
||||
if (std::strcmp(name, "vkCreatePipelineCache") == 0) {
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledCreatePipelineCache);
|
||||
}
|
||||
if (std::strcmp(name, "vkAllocateMemory") == 0) {
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledAllocateMemory);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -1300,18 +1342,134 @@ void ReleaseEtc2PoolCommandBuffers(VkDevice device,
|
||||
}
|
||||
}
|
||||
|
||||
void PrepareEtc2Submit2(std::uint32_t submit_count,
|
||||
const VkSubmitInfo2* submits) {
|
||||
if (submits == nullptr) {
|
||||
return;
|
||||
// The stage the decoded staging bytes are read at. Every emulated upload is
|
||||
// a buffer-to-image copy, so the transfer stage is the earliest point the GPU
|
||||
// may touch them.
|
||||
constexpr VkPipelineStageFlags kEtc2WaitStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
|
||||
|
||||
// Holds the arrays a rewritten submit points at, so they outlive the call.
|
||||
// Deques keep earlier elements addressable as later ones are appended.
|
||||
struct Etc2SubmitRewrite {
|
||||
std::deque<std::vector<VkSemaphore>> wait_semaphores;
|
||||
std::deque<std::vector<VkPipelineStageFlags>> wait_stages;
|
||||
std::deque<std::vector<std::uint64_t>> wait_values;
|
||||
std::deque<std::vector<std::uint64_t>> signal_values;
|
||||
std::deque<VkTimelineSemaphoreSubmitInfo> timelines;
|
||||
std::deque<std::vector<VkSemaphoreSubmitInfo>> wait_infos;
|
||||
std::vector<VkSubmitInfo> rewritten;
|
||||
std::vector<VkSubmitInfo2> rewritten2;
|
||||
};
|
||||
|
||||
// Adds each submit's decode wait to its wait list. A submit whose pNext chain
|
||||
// already sizes arrays by its own semaphore counts is decoded on this thread
|
||||
// instead: a VkTimelineSemaphoreSubmitInfo would have to be rebuilt, and a
|
||||
// VkDeviceGroupSubmitInfo's pWaitSemaphoreDeviceIndices would be left one
|
||||
// entry short of the grown wait list.
|
||||
const VkSubmitInfo* PrepareEtc2Submit(std::uint32_t submit_count,
|
||||
const VkSubmitInfo* submits,
|
||||
Etc2SubmitRewrite* rewrite) {
|
||||
if (submits == nullptr || submit_count == 0) {
|
||||
return submits;
|
||||
}
|
||||
bool rewritten_any = false;
|
||||
for (std::uint32_t index = 0; index < submit_count; ++index) {
|
||||
for (std::uint32_t info = 0; info < submits[index].commandBufferInfoCount;
|
||||
++info) {
|
||||
State().etc2.PrepareSubmit(
|
||||
&submits[index].pCommandBufferInfos[info].commandBuffer, 1);
|
||||
const VkSubmitInfo& original = submits[index];
|
||||
const bool fixed_wait_list =
|
||||
FindFeature(original.pNext,
|
||||
VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) !=
|
||||
nullptr ||
|
||||
FindFeature(original.pNext,
|
||||
VK_STRUCTURE_TYPE_DEVICE_GROUP_SUBMIT_INFO) != nullptr;
|
||||
const mocktail::graphics::Etc2SubmitWait wait = State().etc2.PrepareSubmit(
|
||||
original.pCommandBuffers, original.commandBufferCount,
|
||||
!fixed_wait_list);
|
||||
if (wait.semaphore == VK_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
if (!rewritten_any) {
|
||||
rewrite->rewritten.assign(submits, submits + submit_count);
|
||||
rewritten_any = true;
|
||||
}
|
||||
std::vector<VkSemaphore>& semaphores = rewrite->wait_semaphores.emplace_back(
|
||||
original.pWaitSemaphores,
|
||||
original.pWaitSemaphores + original.waitSemaphoreCount);
|
||||
semaphores.push_back(wait.semaphore);
|
||||
std::vector<VkPipelineStageFlags>& stages =
|
||||
rewrite->wait_stages.emplace_back(
|
||||
original.pWaitDstStageMask,
|
||||
original.pWaitDstStageMask + original.waitSemaphoreCount);
|
||||
stages.push_back(kEtc2WaitStage);
|
||||
// Binary semaphores ignore their value; only the appended entry is read.
|
||||
std::vector<std::uint64_t>& values = rewrite->wait_values.emplace_back(
|
||||
original.waitSemaphoreCount, 0);
|
||||
values.push_back(wait.value);
|
||||
|
||||
VkTimelineSemaphoreSubmitInfo& timeline =
|
||||
rewrite->timelines.emplace_back();
|
||||
timeline.sType = VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO;
|
||||
timeline.pNext = original.pNext;
|
||||
timeline.waitSemaphoreValueCount = static_cast<std::uint32_t>(values.size());
|
||||
timeline.pWaitSemaphoreValues = values.data();
|
||||
if (original.signalSemaphoreCount != 0) {
|
||||
std::vector<std::uint64_t>& signals =
|
||||
rewrite->signal_values.emplace_back(original.signalSemaphoreCount, 0);
|
||||
timeline.signalSemaphoreValueCount =
|
||||
static_cast<std::uint32_t>(signals.size());
|
||||
timeline.pSignalSemaphoreValues = signals.data();
|
||||
}
|
||||
|
||||
VkSubmitInfo& target = rewrite->rewritten[index];
|
||||
target.pNext = &timeline;
|
||||
target.waitSemaphoreCount = static_cast<std::uint32_t>(semaphores.size());
|
||||
target.pWaitSemaphores = semaphores.data();
|
||||
target.pWaitDstStageMask = stages.data();
|
||||
}
|
||||
return rewritten_any ? rewrite->rewritten.data() : submits;
|
||||
}
|
||||
|
||||
const VkSubmitInfo2* PrepareEtc2Submit2(std::uint32_t submit_count,
|
||||
const VkSubmitInfo2* submits,
|
||||
Etc2SubmitRewrite* rewrite) {
|
||||
if (submits == nullptr || submit_count == 0) {
|
||||
return submits;
|
||||
}
|
||||
bool rewritten_any = false;
|
||||
for (std::uint32_t index = 0; index < submit_count; ++index) {
|
||||
const VkSubmitInfo2& original = submits[index];
|
||||
std::vector<VkCommandBuffer> command_buffers;
|
||||
command_buffers.reserve(original.commandBufferInfoCount);
|
||||
for (std::uint32_t info = 0; info < original.commandBufferInfoCount;
|
||||
++info) {
|
||||
command_buffers.push_back(
|
||||
original.pCommandBufferInfos[info].commandBuffer);
|
||||
}
|
||||
const mocktail::graphics::Etc2SubmitWait wait = State().etc2.PrepareSubmit(
|
||||
command_buffers.data(),
|
||||
static_cast<std::uint32_t>(command_buffers.size()), true);
|
||||
if (wait.semaphore == VK_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
if (!rewritten_any) {
|
||||
rewrite->rewritten2.assign(submits, submits + submit_count);
|
||||
rewritten_any = true;
|
||||
}
|
||||
std::vector<VkSemaphoreSubmitInfo>& waits = rewrite->wait_infos.emplace_back(
|
||||
original.pWaitSemaphoreInfos,
|
||||
original.pWaitSemaphoreInfos + original.waitSemaphoreInfoCount);
|
||||
VkSemaphoreSubmitInfo decoded{};
|
||||
decoded.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO;
|
||||
decoded.semaphore = wait.semaphore;
|
||||
decoded.value = wait.value;
|
||||
// Matches kEtc2WaitStage: the staging bytes are read by transfer
|
||||
// commands, which includes the blits an upscaled upload records.
|
||||
decoded.stageMask = VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT;
|
||||
waits.push_back(decoded);
|
||||
|
||||
VkSubmitInfo2& target = rewrite->rewritten2[index];
|
||||
target.waitSemaphoreInfoCount = static_cast<std::uint32_t>(waits.size());
|
||||
target.pWaitSemaphoreInfos = waits.data();
|
||||
}
|
||||
return rewritten_any ? rewrite->rewritten2.data() : submits;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -1664,7 +1822,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(
|
||||
host_memory_properties(physical_device, &memory_properties);
|
||||
}
|
||||
State().etc2.RegisterDevice(*device, physical_device, etc2_emulated,
|
||||
memory_properties, host_get_device_proc_addr);
|
||||
memory_properties, host_get_device_proc_addr,
|
||||
enabled.vulkan12.timelineSemaphore == VK_TRUE);
|
||||
|
||||
std::uint32_t queue_family_count = 0;
|
||||
const auto host_get_queue_families =
|
||||
@@ -2143,14 +2302,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueSubmit", "submit");
|
||||
scope.Arg("submits", submit_count);
|
||||
if (submits != nullptr) {
|
||||
for (std::uint32_t index = 0; index < submit_count; ++index) {
|
||||
State().etc2.PrepareSubmit(submits[index].pCommandBuffers,
|
||||
submits[index].commandBufferCount);
|
||||
}
|
||||
}
|
||||
Etc2SubmitRewrite rewrite;
|
||||
const VkSubmitInfo* prepared =
|
||||
PrepareEtc2Submit(submit_count, submits, &rewrite);
|
||||
const VkResult result = State().text_overlay.QueueSubmit(
|
||||
queue, submit_count, submits, fence, host_submit);
|
||||
queue, submit_count, prepared, fence, host_submit);
|
||||
observation.SetResult(result);
|
||||
return result;
|
||||
}
|
||||
@@ -2168,9 +2324,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2(
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueSubmit2", "submit");
|
||||
scope.Arg("submits", submit_count);
|
||||
PrepareEtc2Submit2(submit_count, submits);
|
||||
Etc2SubmitRewrite rewrite;
|
||||
const VkSubmitInfo2* prepared =
|
||||
PrepareEtc2Submit2(submit_count, submits, &rewrite);
|
||||
const VkResult result = State().text_overlay.QueueSubmit2(
|
||||
queue, submit_count, submits, fence, host_submit);
|
||||
queue, submit_count, prepared, fence, host_submit);
|
||||
observation.SetResult(result);
|
||||
return result;
|
||||
}
|
||||
@@ -2188,9 +2346,11 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2KHR(
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkQueueSubmit2KHR", "submit");
|
||||
scope.Arg("submits", submit_count);
|
||||
PrepareEtc2Submit2(submit_count, submits);
|
||||
Etc2SubmitRewrite rewrite;
|
||||
const VkSubmitInfo2* prepared =
|
||||
PrepareEtc2Submit2(submit_count, submits, &rewrite);
|
||||
const VkResult result = State().text_overlay.QueueSubmit2(
|
||||
queue, submit_count, submits, fence,
|
||||
queue, submit_count, prepared, fence,
|
||||
reinterpret_cast<PFN_vkQueueSubmit2>(host_submit));
|
||||
observation.SetResult(result);
|
||||
return result;
|
||||
@@ -2533,11 +2693,15 @@ VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceImageFormatProperties2(
|
||||
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(
|
||||
VkDevice device, const VkImageCreateInfo* create_info,
|
||||
const VkAllocationCallbacks* allocator, VkImage* image) {
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkCreateImage", "memory");
|
||||
return State().etc2.CreateImage(device, create_info, allocator, image);
|
||||
}
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL vkDestroyImage(
|
||||
VkDevice device, VkImage image, const VkAllocationCallbacks* allocator) {
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkDestroyImage", "memory");
|
||||
State().etc2.DestroyImage(device, image, allocator);
|
||||
}
|
||||
|
||||
@@ -2566,6 +2730,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory(VkDevice device,
|
||||
VkDeviceSize size,
|
||||
VkMemoryMapFlags flags,
|
||||
void** data) {
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkMapMemory", "memory");
|
||||
return State().etc2.MapMemory(device, memory, offset, size, flags, data);
|
||||
}
|
||||
|
||||
@@ -2577,6 +2743,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory2(VkDevice device,
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL vkUnmapMemory(VkDevice device,
|
||||
VkDeviceMemory memory) {
|
||||
// Includes waiting for ETC2 decodes that still read this memory.
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkUnmapMemory", "memory");
|
||||
State().etc2.UnmapMemory(device, memory);
|
||||
}
|
||||
|
||||
@@ -2588,6 +2757,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkUnmapMemory2(VkDevice device,
|
||||
VKAPI_ATTR void VKAPI_CALL vkFreeMemory(
|
||||
VkDevice device, VkDeviceMemory memory,
|
||||
const VkAllocationCallbacks* allocator) {
|
||||
// Includes waiting for ETC2 decodes that still read this memory.
|
||||
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
|
||||
"vkFreeMemory", "memory");
|
||||
State().etc2.FreeMemory(device, memory, allocator);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
@@ -106,6 +107,53 @@ std::uint64_t TraceClockNanos() {
|
||||
static_cast<std::uint64_t>(now.tv_nsec);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::uint64_t ClockNanos(clockid_t clock) {
|
||||
timespec now{};
|
||||
clock_gettime(clock, &now);
|
||||
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
|
||||
static_cast<std::uint64_t>(now.tv_nsec);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::uint64_t TraceThreadCpuNanos() {
|
||||
return ClockNanos(CLOCK_THREAD_CPUTIME_ID);
|
||||
}
|
||||
|
||||
std::uint64_t TraceProcessCpuNanos() {
|
||||
return ClockNanos(CLOCK_PROCESS_CPUTIME_ID);
|
||||
}
|
||||
|
||||
bool FrameCpuSampler::Sample(std::uint64_t thread_id,
|
||||
std::uint64_t thread_cpu_ns,
|
||||
std::uint64_t process_cpu_ns,
|
||||
FrameCpuSample* sample) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const bool had_previous = has_previous_;
|
||||
// Clocks are read before the lock, so a reading can arrive after a later
|
||||
// one; a reused thread id restarts its thread clock. Neither may wrap.
|
||||
if (had_previous) {
|
||||
*sample = {};
|
||||
if (process_cpu_ns > previous_process_cpu_ns_) {
|
||||
sample->process_ms =
|
||||
static_cast<double>(process_cpu_ns - previous_process_cpu_ns_) / 1e6;
|
||||
}
|
||||
if (thread_id == previous_thread_id_ &&
|
||||
thread_cpu_ns >= previous_thread_cpu_ns_) {
|
||||
sample->has_thread = true;
|
||||
sample->thread_ms =
|
||||
static_cast<double>(thread_cpu_ns - previous_thread_cpu_ns_) / 1e6;
|
||||
}
|
||||
}
|
||||
has_previous_ = true;
|
||||
previous_thread_id_ = thread_id;
|
||||
previous_thread_cpu_ns_ = thread_cpu_ns;
|
||||
previous_process_cpu_ns_ = std::max(previous_process_cpu_ns_, process_cpu_ns);
|
||||
return had_previous;
|
||||
}
|
||||
|
||||
std::unique_ptr<ChromeTraceWriter> ChromeTraceWriter::Open(
|
||||
const std::filesystem::path& path, std::string* error) {
|
||||
std::FILE* file = std::fopen(path.c_str(), "wb");
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
@@ -157,6 +159,13 @@ struct HostDevice {
|
||||
PFN_vkCreateBuffer create_buffer = nullptr;
|
||||
PFN_vkAllocateMemory allocate_memory = nullptr;
|
||||
PFN_vkGetBufferMemoryRequirements get_buffer_memory_requirements = nullptr;
|
||||
PFN_vkCmdPipelineBarrier pipeline_barrier = nullptr;
|
||||
PFN_vkSignalSemaphore signal_semaphore = nullptr;
|
||||
PFN_vkDestroySemaphore destroy_semaphore = nullptr;
|
||||
// Signalled by the decode dispatcher so a submit can wait for its uploads
|
||||
// on the GPU instead of on the submitting thread. Null when the device was
|
||||
// created without the timelineSemaphore feature.
|
||||
VkSemaphore timeline = VK_NULL_HANDLE;
|
||||
};
|
||||
|
||||
template <typename Function>
|
||||
@@ -202,22 +211,57 @@ bool IsColorFormat(EtcFormat format) {
|
||||
}
|
||||
|
||||
// A host-visible transfer buffer that stays mapped for its whole life.
|
||||
struct Staging {
|
||||
// Uploads take ranges of it front to back, and once every range has been
|
||||
// released it starts over from the front. Creating a buffer and destroying
|
||||
// it each cost the driver a kernel call of a tenth of a millisecond or more,
|
||||
// so a join's thousands of uploads share a few blocks instead.
|
||||
struct StagingBlock {
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
VkBuffer buffer = VK_NULL_HANDLE;
|
||||
VkDeviceMemory memory = VK_NULL_HANDLE;
|
||||
VkDeviceSize capacity = 0;
|
||||
std::uint8_t* mapped = nullptr;
|
||||
// First byte not yet handed out, and the ranges command buffers hold.
|
||||
VkDeviceSize head = 0;
|
||||
std::size_t live = 0;
|
||||
};
|
||||
|
||||
// Idle staging buffers kept for reuse. Creating, allocating, binding and
|
||||
// mapping one costs hundreds of microseconds in the driver, so a burst of
|
||||
// texture uploads would otherwise stall the frame that records it.
|
||||
constexpr VkDeviceSize kMaxIdleStagingBytes = 64 * 1024 * 1024;
|
||||
constexpr std::size_t kMaxIdleStagingBuffers = 64;
|
||||
// A request may take an idle buffer up to this size, or up to 4x its own
|
||||
// size when larger, so small uploads share buffers without pinning big ones.
|
||||
constexpr VkDeviceSize kStagingReuseSlack = 64 * 1024;
|
||||
// One recorded copy's range of a staging block.
|
||||
struct Staging {
|
||||
StagingBlock* block = nullptr;
|
||||
VkDeviceSize offset = 0;
|
||||
};
|
||||
|
||||
// Ranges start on this boundary. It meets the bufferOffset rule of every
|
||||
// host format and keeps ranges written by different threads off shared cache
|
||||
// lines.
|
||||
constexpr VkDeviceSize kStagingAlignment = 256;
|
||||
// Empty blocks kept for reuse, counted in block sizes; the rest are
|
||||
// destroyed.
|
||||
constexpr VkDeviceSize kIdleStagingBlocks = 4;
|
||||
// Decode batches kept for their buffers once they complete.
|
||||
constexpr std::size_t kMaxSpareBatches = 8;
|
||||
|
||||
// A growable byte buffer that leaves new bytes uninitialized. The gather
|
||||
// writes every byte it later reads, and zeroing a multi-megabyte buffer is
|
||||
// itself frame-visible work.
|
||||
class RawBuffer {
|
||||
public:
|
||||
void EnsureSize(std::size_t bytes) {
|
||||
if (bytes <= size_) {
|
||||
return;
|
||||
}
|
||||
// new[] on a trivial type default-initializes, so nothing is zeroed.
|
||||
data_.reset(new std::uint8_t[bytes]);
|
||||
size_ = bytes;
|
||||
}
|
||||
std::uint8_t* data() { return data_.get(); }
|
||||
std::size_t size() const { return size_; }
|
||||
|
||||
private:
|
||||
std::unique_ptr<std::uint8_t[]> data_;
|
||||
std::size_t size_ = 0;
|
||||
};
|
||||
|
||||
struct PendingUpload {
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
@@ -257,6 +301,32 @@ struct CommandRecord {
|
||||
std::vector<PendingUpload> uploads;
|
||||
std::vector<Staging> staging;
|
||||
std::vector<VkCommandBuffer> secondaries;
|
||||
// Highest decode ticket dispatched for this command buffer. Its staging
|
||||
// must not be released before that ticket completes.
|
||||
std::uint64_t last_ticket = 0;
|
||||
};
|
||||
|
||||
// One submit's worth of uploads, decoded off the submitting thread. The
|
||||
// buffers and the upload list are owned so nothing in them depends on the
|
||||
// caller's stack.
|
||||
struct DecodeBatch {
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
std::uint64_t ticket = 0;
|
||||
RawBuffer source;
|
||||
std::vector<std::uint8_t> decoded;
|
||||
// Bytes each buffer must hold. Both are grown on the dispatcher, since
|
||||
// sizing them on the submitting thread puts that cost back on the frame.
|
||||
// `decoded` stays a zeroed vector so a failed decode leaves defined bytes
|
||||
// rather than whatever the allocator handed back.
|
||||
VkDeviceSize source_bytes = 0;
|
||||
VkDeviceSize decoded_bytes = 0;
|
||||
std::vector<PendingUpload> uploads;
|
||||
// The application's mapped pointer for each upload, parallel to `uploads`.
|
||||
// Non-empty when the gather was deferred to the dispatcher, which Vulkan
|
||||
// allows: the application may not write these bytes between the submit and
|
||||
// the copy's completion. Unmapping or freeing that memory drains pending
|
||||
// decodes first, so the pointers stay dereferenceable.
|
||||
std::vector<const std::uint8_t*> deferred_sources;
|
||||
};
|
||||
|
||||
std::atomic<unsigned> g_image_logs{0};
|
||||
@@ -269,9 +339,8 @@ void LogFailure(const char* message) {
|
||||
}
|
||||
}
|
||||
|
||||
bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
|
||||
std::uint8_t** mapped) {
|
||||
*mapped = nullptr;
|
||||
bool CreateStagingBlock(const HostDevice& dev, VkDeviceSize size,
|
||||
StagingBlock* block) {
|
||||
if (dev.create_buffer == nullptr || dev.allocate_memory == nullptr ||
|
||||
dev.get_buffer_memory_requirements == nullptr ||
|
||||
dev.bind_buffer_memory == nullptr || dev.map_memory == nullptr ||
|
||||
@@ -321,18 +390,70 @@ bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
|
||||
dev.free_memory(dev.device, memory, nullptr);
|
||||
return false;
|
||||
}
|
||||
*mapped = static_cast<std::uint8_t*>(data);
|
||||
*staging = {dev.device, buffer, memory, size, *mapped};
|
||||
block->device = dev.device;
|
||||
block->buffer = buffer;
|
||||
block->memory = memory;
|
||||
block->capacity = size;
|
||||
block->mapped = static_cast<std::uint8_t*>(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Packs each upload's compressed bytes into `destination`, dropping the row
|
||||
// and layer padding the application may have asked for. Sources are parallel
|
||||
// to `uploads`.
|
||||
void GatherCompressed(const std::vector<PendingUpload>& uploads,
|
||||
const std::vector<const std::uint8_t*>& sources,
|
||||
std::uint8_t* destination) {
|
||||
VkDeviceSize offset = 0;
|
||||
for (std::size_t index = 0; index < uploads.size(); ++index) {
|
||||
const PendingUpload& upload = uploads[index];
|
||||
const std::uint8_t* source = sources[index];
|
||||
std::uint8_t* target = destination + offset;
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
if (upload.source_span == upload.compressed) {
|
||||
std::memcpy(target, source, upload.compressed);
|
||||
} else {
|
||||
const VkDeviceSize block_rows =
|
||||
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
||||
const VkDeviceSize packed_row = compressed_layer / block_rows;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
for (VkDeviceSize row = 0; row < block_rows; ++row) {
|
||||
std::memcpy(target + layer * compressed_layer + row * packed_row,
|
||||
source + layer * upload.source_layer_stride +
|
||||
row * upload.source_row_stride,
|
||||
packed_row);
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += upload.compressed;
|
||||
}
|
||||
}
|
||||
|
||||
// Makes the decode's host writes to staging visible to the transfer that
|
||||
// reads them. A queue submission performs the host-to-device domain operation
|
||||
// only for writes that happened before it, and an asynchronous decode writes
|
||||
// after the submit, so the dependency has to be recorded explicitly.
|
||||
void RecordHostWriteBarrier(const HostDevice& dev,
|
||||
VkCommandBuffer command_buffer) {
|
||||
if (dev.pipeline_barrier == nullptr) {
|
||||
return;
|
||||
}
|
||||
VkMemoryBarrier barrier{};
|
||||
barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
|
||||
barrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT;
|
||||
barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
|
||||
dev.pipeline_barrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT,
|
||||
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &barrier, 0,
|
||||
nullptr, 0, nullptr);
|
||||
}
|
||||
|
||||
// Freeing mapped memory unmaps it implicitly.
|
||||
void DestroyStaging(const HostDevice& dev, const Staging& staging) {
|
||||
void DestroyStagingBlock(const HostDevice& dev, const StagingBlock& block) {
|
||||
if (dev.destroy_buffer != nullptr) {
|
||||
dev.destroy_buffer(dev.device, staging.buffer, nullptr);
|
||||
dev.destroy_buffer(dev.device, block.buffer, nullptr);
|
||||
}
|
||||
if (dev.free_memory != nullptr) {
|
||||
dev.free_memory(dev.device, staging.memory, nullptr);
|
||||
dev.free_memory(dev.device, block.memory, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +617,9 @@ bool PlanRegions(const ImageRecord& record, VkDevice device, VkBuffer source,
|
||||
} // namespace
|
||||
|
||||
struct VulkanEtc2Emulation::State {
|
||||
explicit State(VkDeviceSize block_bytes)
|
||||
: staging_block_bytes(block_bytes) {}
|
||||
|
||||
std::mutex mutex;
|
||||
std::mutex devices_mutex;
|
||||
std::unordered_map<VkPhysicalDevice, bool> physical_devices;
|
||||
@@ -505,11 +629,13 @@ struct VulkanEtc2Emulation::State {
|
||||
std::unordered_map<VkDeviceMemory, Mapping> mappings;
|
||||
std::unordered_map<VkCommandBuffer, CommandRecord> commands;
|
||||
std::atomic<bool> has_commands{false};
|
||||
std::vector<Staging> idle_staging;
|
||||
VkDeviceSize idle_staging_bytes = 0;
|
||||
// Blocks of every device, in creation order. Guarded by `mutex`; a block's
|
||||
// address is stable until it is destroyed.
|
||||
const VkDeviceSize staging_block_bytes;
|
||||
std::vector<std::unique_ptr<StagingBlock>> staging_blocks;
|
||||
// Heap buffers reused across submits; they only grow.
|
||||
std::mutex scratch_mutex;
|
||||
std::vector<std::uint8_t> scratch_source;
|
||||
RawBuffer scratch_source;
|
||||
std::vector<std::uint8_t> scratch_decoded;
|
||||
TextureOverrides overrides = TextureOverrides::FromEnvironment();
|
||||
const std::uint32_t upscale =
|
||||
@@ -658,51 +784,78 @@ struct VulkanEtc2Emulation::State {
|
||||
static_cast<unsigned>(threads));
|
||||
}
|
||||
|
||||
// Takes the smallest idle buffer that fits, or creates one.
|
||||
// Takes `size` bytes from the first block of the device with room for
|
||||
// them, or from a new block of at least `staging_block_bytes`.
|
||||
bool AcquireStaging(const HostDevice& dev, VkDeviceSize size,
|
||||
Staging* staging) {
|
||||
const VkDeviceSize aligned =
|
||||
(size + kStagingAlignment - 1) / kStagingAlignment * kStagingAlignment;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const VkDeviceSize limit = std::max(size * 4, kStagingReuseSlack);
|
||||
auto best = idle_staging.end();
|
||||
for (auto it = idle_staging.begin(); it != idle_staging.end(); ++it) {
|
||||
if (it->device == dev.device && it->capacity >= size &&
|
||||
it->capacity <= limit &&
|
||||
(best == idle_staging.end() || it->capacity < best->capacity)) {
|
||||
best = it;
|
||||
for (const auto& block : staging_blocks) {
|
||||
if (block->device == dev.device &&
|
||||
block->capacity - block->head >= aligned) {
|
||||
*staging = {block.get(), block->head};
|
||||
block->head += aligned;
|
||||
++block->live;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (best != idle_staging.end()) {
|
||||
*staging = *best;
|
||||
idle_staging_bytes -= best->capacity;
|
||||
*best = idle_staging.back();
|
||||
idle_staging.pop_back();
|
||||
return true;
|
||||
}
|
||||
// Created outside the lock, which the decode workers also take.
|
||||
auto block = std::make_unique<StagingBlock>();
|
||||
{
|
||||
const VkDeviceSize capacity = std::max(staging_block_bytes, aligned);
|
||||
TraceScope trace_scope(ActiveProfileTrace(), "etc2 staging create",
|
||||
"texture");
|
||||
trace_scope.Arg("bytes", static_cast<std::int64_t>(capacity));
|
||||
if (!CreateStagingBlock(dev, capacity, block.get())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::uint8_t* mapped = nullptr;
|
||||
return CreateStaging(dev, size, staging, &mapped);
|
||||
block->head = aligned;
|
||||
block->live = 1;
|
||||
*staging = {block.get(), 0};
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
staging_blocks.push_back(std::move(block));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keeps released buffers for reuse up to the idle limits and destroys
|
||||
// the rest.
|
||||
void ReleaseStaging(std::vector<Staging> released) {
|
||||
std::vector<Staging> doomed;
|
||||
// Returns ranges to their blocks. A block with no ranges left starts over
|
||||
// from its front; empty blocks beyond kIdleStagingBlocks block sizes are
|
||||
// destroyed.
|
||||
void ReleaseStaging(const std::vector<Staging>& released) {
|
||||
std::vector<std::unique_ptr<StagingBlock>> doomed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (const Staging& staging : released) {
|
||||
if (idle_staging.size() < kMaxIdleStagingBuffers &&
|
||||
idle_staging_bytes + staging.capacity <= kMaxIdleStagingBytes) {
|
||||
idle_staging.push_back(staging);
|
||||
idle_staging_bytes += staging.capacity;
|
||||
} else {
|
||||
doomed.push_back(staging);
|
||||
if (--staging.block->live == 0) {
|
||||
staging.block->head = 0;
|
||||
}
|
||||
}
|
||||
const VkDeviceSize idle_limit = kIdleStagingBlocks * staging_block_bytes;
|
||||
VkDeviceSize idle_bytes = 0;
|
||||
for (auto it = staging_blocks.begin(); it != staging_blocks.end();) {
|
||||
if ((*it)->live == 0 && idle_bytes + (*it)->capacity > idle_limit) {
|
||||
doomed.push_back(std::move(*it));
|
||||
it = staging_blocks.erase(it);
|
||||
continue;
|
||||
}
|
||||
if ((*it)->live == 0) {
|
||||
idle_bytes += (*it)->capacity;
|
||||
}
|
||||
++it;
|
||||
}
|
||||
}
|
||||
for (const Staging& staging : doomed) {
|
||||
if (const HostDevice* dev = Find(staging.device); dev != nullptr) {
|
||||
DestroyStaging(*dev, staging);
|
||||
if (doomed.empty()) {
|
||||
return;
|
||||
}
|
||||
TraceScope trace_scope(ActiveProfileTrace(), "etc2 staging destroy",
|
||||
"texture");
|
||||
trace_scope.Arg("buffers", static_cast<std::int64_t>(doomed.size()));
|
||||
for (const auto& block : doomed) {
|
||||
if (const HostDevice* dev = Find(block->device); dev != nullptr) {
|
||||
DestroyStagingBlock(*dev, *block);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,11 +898,204 @@ struct VulkanEtc2Emulation::State {
|
||||
record.staging.push_back(staging);
|
||||
has_commands.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
// Asynchronous decode. One dispatcher runs batches in the order tickets
|
||||
// were handed out, so the timeline semaphore is always signalled with an
|
||||
// increasing value and a waiter never observes a later batch's signal.
|
||||
std::mutex queue_mutex;
|
||||
std::condition_variable queue_ready;
|
||||
std::condition_variable queue_done;
|
||||
std::deque<std::unique_ptr<DecodeBatch>> queue;
|
||||
std::vector<std::unique_ptr<DecodeBatch>> spare_batches;
|
||||
std::uint64_t issued_ticket = 0;
|
||||
std::uint64_t completed_ticket = 0;
|
||||
bool stopping = false;
|
||||
std::thread dispatcher;
|
||||
|
||||
// Recycles a batch's buffers; they only grow, as the scratch buffers do.
|
||||
std::unique_ptr<DecodeBatch> AcquireBatch() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
if (spare_batches.empty()) {
|
||||
return std::make_unique<DecodeBatch>();
|
||||
}
|
||||
std::unique_ptr<DecodeBatch> batch = std::move(spare_batches.back());
|
||||
spare_batches.pop_back();
|
||||
batch->uploads.clear();
|
||||
return batch;
|
||||
}
|
||||
|
||||
void StartDispatcher() {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
if (dispatcher.joinable() || stopping) {
|
||||
return;
|
||||
}
|
||||
dispatcher = std::thread([this] { RunDispatcher(); });
|
||||
}
|
||||
|
||||
// Hands out the next ticket and queues the batch behind it.
|
||||
std::uint64_t Enqueue(std::unique_ptr<DecodeBatch> batch) {
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
batch->ticket = ++issued_ticket;
|
||||
const std::uint64_t ticket = batch->ticket;
|
||||
queue.push_back(std::move(batch));
|
||||
queue_ready.notify_one();
|
||||
return ticket;
|
||||
}
|
||||
|
||||
void RunDispatcher() {
|
||||
for (;;) {
|
||||
std::unique_ptr<DecodeBatch> batch;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
queue_ready.wait(lock, [this] { return stopping || !queue.empty(); });
|
||||
if (queue.empty()) {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
batch = std::move(queue.front());
|
||||
queue.pop_front();
|
||||
}
|
||||
RunBatch(*batch);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
completed_ticket = batch->ticket;
|
||||
if (spare_batches.size() < kMaxSpareBatches) {
|
||||
spare_batches.push_back(std::move(batch));
|
||||
}
|
||||
queue_done.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RunBatch(DecodeBatch& batch) {
|
||||
TraceScope trace_scope(ActiveProfileTrace(), "etc2 decode", "texture");
|
||||
trace_scope.Arg("uploads", static_cast<std::int64_t>(batch.uploads.size()));
|
||||
trace_scope.Arg("compressed_bytes",
|
||||
static_cast<std::int64_t>(batch.source_bytes));
|
||||
trace_scope.Arg("decoded_bytes",
|
||||
static_cast<std::int64_t>(batch.decoded_bytes));
|
||||
batch.source.EnsureSize(batch.source_bytes);
|
||||
if (batch.decoded.size() < batch.decoded_bytes) {
|
||||
batch.decoded.resize(batch.decoded_bytes);
|
||||
}
|
||||
if (!batch.deferred_sources.empty()) {
|
||||
GatherCompressed(batch.uploads, batch.deferred_sources,
|
||||
batch.source.data());
|
||||
batch.deferred_sources.clear();
|
||||
}
|
||||
DecodeAndEmit(batch.uploads, batch.source.data(), batch.decoded.data());
|
||||
SignalDecoded(batch.device, batch.ticket);
|
||||
}
|
||||
|
||||
void SignalDecoded(VkDevice device, std::uint64_t value) {
|
||||
const HostDevice* dev = Find(device);
|
||||
if (dev == nullptr || dev->timeline == VK_NULL_HANDLE ||
|
||||
dev->signal_semaphore == nullptr) {
|
||||
return;
|
||||
}
|
||||
VkSemaphoreSignalInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO;
|
||||
info.semaphore = dev->timeline;
|
||||
info.value = value;
|
||||
if (dev->signal_semaphore(dev->device, &info) != VK_SUCCESS) {
|
||||
LogFailure("could not signal the decode timeline semaphore");
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes every layer of every upload and writes the host texels. Shared by
|
||||
// the dispatcher and by the synchronous path.
|
||||
void DecodeAndEmit(const std::vector<PendingUpload>& uploads,
|
||||
std::uint8_t* source_base, std::uint8_t* decoded_base) {
|
||||
std::vector<EtcDecodeJob> jobs;
|
||||
VkDeviceSize compressed_offset = 0;
|
||||
VkDeviceSize decoded_offset = 0;
|
||||
for (const PendingUpload& upload : uploads) {
|
||||
std::uint8_t* source = source_base + compressed_offset;
|
||||
std::uint8_t* decoded = decoded_base + decoded_offset;
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
const VkDeviceSize decoded_layer = upload.decoded / upload.layers;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
EtcDecodeJob job;
|
||||
job.format = upload.format;
|
||||
job.source = source + layer * compressed_layer;
|
||||
job.source_bytes = compressed_layer;
|
||||
job.width = upload.width;
|
||||
job.height = upload.height;
|
||||
job.destination = decoded + layer * decoded_layer;
|
||||
job.destination_bytes = decoded_layer;
|
||||
jobs.push_back(job);
|
||||
}
|
||||
compressed_offset += upload.compressed;
|
||||
decoded_offset += upload.decoded;
|
||||
if (ShouldLog(&g_decode_logs, 4)) {
|
||||
std::fprintf(stderr, " [vulkan] ETC2 upload decoded %ux%u layers=%u\n",
|
||||
upload.width, upload.height, upload.layers);
|
||||
}
|
||||
}
|
||||
DecodeEtcJobs(jobs.data(), jobs.size(), DecodeWorkerCount());
|
||||
for (const EtcDecodeJob& job : jobs) {
|
||||
if (!job.ok) {
|
||||
LogFailure("could not decode an upload layer");
|
||||
}
|
||||
}
|
||||
compressed_offset = 0;
|
||||
decoded_offset = 0;
|
||||
std::vector<EmitJob> emit_jobs;
|
||||
emit_jobs.reserve(uploads.size());
|
||||
for (const PendingUpload& upload : uploads) {
|
||||
emit_jobs.push_back({&upload, source_base + compressed_offset,
|
||||
decoded_base + decoded_offset});
|
||||
compressed_offset += upload.compressed;
|
||||
decoded_offset += upload.decoded;
|
||||
}
|
||||
EmitBatch(std::move(emit_jobs), DecodeWorkerCount());
|
||||
}
|
||||
|
||||
// Blocks until every batch up to `ticket` has been decoded and emitted.
|
||||
void WaitForTicket(std::uint64_t ticket) {
|
||||
if (ticket == 0) {
|
||||
return;
|
||||
}
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
queue_done.wait(lock, [this, ticket] {
|
||||
return completed_ticket >= ticket || stopping;
|
||||
});
|
||||
}
|
||||
|
||||
// Blocks until nothing is queued or in flight.
|
||||
void DrainDecodes() {
|
||||
std::uint64_t ticket = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
ticket = issued_ticket;
|
||||
}
|
||||
WaitForTicket(ticket);
|
||||
}
|
||||
|
||||
void StopDispatcher() {
|
||||
std::thread worker;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(queue_mutex);
|
||||
stopping = true;
|
||||
queue_ready.notify_all();
|
||||
queue_done.notify_all();
|
||||
worker = std::move(dispatcher);
|
||||
}
|
||||
if (worker.joinable()) {
|
||||
worker.join();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
VulkanEtc2Emulation::VulkanEtc2Emulation() : state_(new State) {}
|
||||
VulkanEtc2Emulation::VulkanEtc2Emulation(VkDeviceSize staging_block_bytes)
|
||||
: state_(new State(staging_block_bytes)) {}
|
||||
|
||||
VulkanEtc2Emulation::~VulkanEtc2Emulation() { delete state_; }
|
||||
VulkanEtc2Emulation::~VulkanEtc2Emulation() {
|
||||
state_->StopDispatcher();
|
||||
delete state_;
|
||||
}
|
||||
|
||||
bool VulkanEtc2Emulation::PhysicalDeviceNeedsEmulation(
|
||||
VkPhysicalDevice physical_device, PFN_vkGetPhysicalDeviceFeatures host) {
|
||||
@@ -775,7 +1121,7 @@ bool VulkanEtc2Emulation::PhysicalDeviceNeedsEmulation(
|
||||
void VulkanEtc2Emulation::RegisterDevice(
|
||||
VkDevice device, VkPhysicalDevice physical_device, bool emulated,
|
||||
const VkPhysicalDeviceMemoryProperties& memory,
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr) {
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr, bool timeline_semaphore) {
|
||||
static_cast<void>(physical_device);
|
||||
if (device == VK_NULL_HANDLE || get_device_proc_addr == nullptr) {
|
||||
return;
|
||||
@@ -821,6 +1167,31 @@ void VulkanEtc2Emulation::RegisterDevice(
|
||||
dev->get_buffer_memory_requirements =
|
||||
DeviceProc<PFN_vkGetBufferMemoryRequirements>(
|
||||
get, device, "vkGetBufferMemoryRequirements");
|
||||
dev->pipeline_barrier =
|
||||
DeviceProc<PFN_vkCmdPipelineBarrier>(get, device, "vkCmdPipelineBarrier");
|
||||
dev->signal_semaphore = DeviceProc<PFN_vkSignalSemaphore>(
|
||||
get, device, "vkSignalSemaphore", "vkSignalSemaphoreKHR");
|
||||
dev->destroy_semaphore =
|
||||
DeviceProc<PFN_vkDestroySemaphore>(get, device, "vkDestroySemaphore");
|
||||
if (emulated && timeline_semaphore && dev->signal_semaphore != nullptr) {
|
||||
const auto create_semaphore =
|
||||
DeviceProc<PFN_vkCreateSemaphore>(get, device, "vkCreateSemaphore");
|
||||
VkSemaphoreTypeCreateInfo type{};
|
||||
type.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO;
|
||||
type.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE;
|
||||
type.initialValue = 0;
|
||||
VkSemaphoreCreateInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
||||
info.pNext = &type;
|
||||
if (create_semaphore != nullptr &&
|
||||
create_semaphore(device, &info, nullptr, &dev->timeline) !=
|
||||
VK_SUCCESS) {
|
||||
dev->timeline = VK_NULL_HANDLE;
|
||||
}
|
||||
if (dev->timeline != VK_NULL_HANDLE) {
|
||||
state_->StartDispatcher();
|
||||
}
|
||||
}
|
||||
if (emulated) {
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] ETC2/EAC emulation enabled: compressed uploads "
|
||||
@@ -837,27 +1208,24 @@ void VulkanEtc2Emulation::RegisterDevice(
|
||||
}
|
||||
|
||||
void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
std::vector<Staging> doomed;
|
||||
// Nothing may free staging or the semaphore while a decode still writes it.
|
||||
state_->DrainDecodes();
|
||||
std::vector<std::unique_ptr<StagingBlock>> doomed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
for (auto it = state_->commands.begin(); it != state_->commands.end();) {
|
||||
const bool owned = std::any_of(
|
||||
it->second.staging.begin(), it->second.staging.end(),
|
||||
[device](const Staging& staging) { return staging.device == device; });
|
||||
if (owned) {
|
||||
doomed.insert(doomed.end(), it->second.staging.begin(),
|
||||
it->second.staging.end());
|
||||
it = state_->commands.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
[device](const Staging& staging) {
|
||||
return staging.block->device == device;
|
||||
});
|
||||
it = owned ? state_->commands.erase(it) : std::next(it);
|
||||
}
|
||||
for (auto it = state_->idle_staging.begin();
|
||||
it != state_->idle_staging.end();) {
|
||||
if (it->device == device) {
|
||||
state_->idle_staging_bytes -= it->capacity;
|
||||
doomed.push_back(*it);
|
||||
it = state_->idle_staging.erase(it);
|
||||
auto& blocks = state_->staging_blocks;
|
||||
for (auto it = blocks.begin(); it != blocks.end();) {
|
||||
if ((*it)->device == device) {
|
||||
doomed.push_back(std::move(*it));
|
||||
it = blocks.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
@@ -869,8 +1237,11 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
std::memory_order_release);
|
||||
}
|
||||
if (const HostDevice* dev = state_->Find(device); dev != nullptr) {
|
||||
for (const Staging& staging : doomed) {
|
||||
DestroyStaging(*dev, staging);
|
||||
for (const auto& block : doomed) {
|
||||
DestroyStagingBlock(*dev, *block);
|
||||
}
|
||||
if (dev->timeline != VK_NULL_HANDLE && dev->destroy_semaphore != nullptr) {
|
||||
dev->destroy_semaphore(device, dev->timeline, nullptr);
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(state_->devices_mutex);
|
||||
@@ -1035,6 +1406,9 @@ void VulkanEtc2Emulation::UnmapMemory(VkDevice device, VkDeviceMemory memory) {
|
||||
return;
|
||||
}
|
||||
if (dev->emulated) {
|
||||
// A deferred gather reads this mapping on a worker, so it must finish
|
||||
// before the pointer stops being valid.
|
||||
state_->DrainDecodes();
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->mappings.erase(memory);
|
||||
}
|
||||
@@ -1048,6 +1422,7 @@ VkResult VulkanEtc2Emulation::UnmapMemory2(VkDevice device,
|
||||
return VK_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
if (dev->emulated && info != nullptr) {
|
||||
state_->DrainDecodes();
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->mappings.erase(info->memory);
|
||||
}
|
||||
@@ -1061,6 +1436,7 @@ void VulkanEtc2Emulation::FreeMemory(VkDevice device, VkDeviceMemory memory,
|
||||
return;
|
||||
}
|
||||
if (dev->emulated) {
|
||||
state_->DrainDecodes();
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->mappings.erase(memory);
|
||||
}
|
||||
@@ -1108,10 +1484,14 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage(
|
||||
return;
|
||||
}
|
||||
for (PendingUpload& upload : uploads) {
|
||||
upload.target = staging.mapped + upload.target_offset;
|
||||
upload.target = staging.block->mapped + staging.offset + upload.target_offset;
|
||||
}
|
||||
for (VkBufferImageCopy& region : rewritten) {
|
||||
region.bufferOffset += staging.offset;
|
||||
}
|
||||
state_->Record(command_buffer, std::move(uploads), staging);
|
||||
dev->copy_buffer_to_image(command_buffer, staging.buffer, destination,
|
||||
RecordHostWriteBarrier(*dev, command_buffer);
|
||||
dev->copy_buffer_to_image(command_buffer, staging.block->buffer, destination,
|
||||
layout, region_count, rewritten.data());
|
||||
}
|
||||
|
||||
@@ -1143,11 +1523,15 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage2(
|
||||
return;
|
||||
}
|
||||
for (PendingUpload& upload : uploads) {
|
||||
upload.target = staging.mapped + upload.target_offset;
|
||||
upload.target = staging.block->mapped + staging.offset + upload.target_offset;
|
||||
}
|
||||
for (VkBufferImageCopy2& region : rewritten) {
|
||||
region.bufferOffset += staging.offset;
|
||||
}
|
||||
state_->Record(command_buffer, std::move(uploads), staging);
|
||||
RecordHostWriteBarrier(*dev, command_buffer);
|
||||
VkCopyBufferToImageInfo2 host_info = *info;
|
||||
host_info.srcBuffer = staging.buffer;
|
||||
host_info.srcBuffer = staging.block->buffer;
|
||||
host_info.pRegions = rewritten.data();
|
||||
dev->copy_buffer_to_image2(command_buffer, &host_info);
|
||||
}
|
||||
@@ -1261,11 +1645,12 @@ void VulkanEtc2Emulation::CmdExecuteCommands(
|
||||
dev->execute_commands(command_buffer, count, secondaries);
|
||||
}
|
||||
|
||||
void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
std::uint32_t count) {
|
||||
Etc2SubmitWait VulkanEtc2Emulation::PrepareSubmit(
|
||||
const VkCommandBuffer* command_buffers, std::uint32_t count,
|
||||
bool allow_async) {
|
||||
if (command_buffers == nullptr || count == 0 ||
|
||||
!state_->has_commands.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
struct Work {
|
||||
PendingUpload upload;
|
||||
@@ -1323,8 +1708,26 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
}
|
||||
}
|
||||
}
|
||||
TraceScope trace_scope(work.empty() ? nullptr : ActiveProfileTrace(),
|
||||
"etc2 decode", "texture");
|
||||
if (work.empty()) {
|
||||
return {};
|
||||
}
|
||||
// A submit carries one device, but a batch that somehow spans two cannot be
|
||||
// signalled by a single timeline, so it decodes on this thread instead.
|
||||
VkDevice batch_device = work.front().upload.device;
|
||||
bool single_device = true;
|
||||
for (const Work& item : work) {
|
||||
if (item.upload.device != batch_device) {
|
||||
single_device = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const HostDevice* batch_dev = state_->Find(batch_device);
|
||||
const bool async = allow_async && single_device && batch_dev != nullptr &&
|
||||
batch_dev->timeline != VK_NULL_HANDLE &&
|
||||
batch_dev->signal_semaphore != nullptr;
|
||||
|
||||
TraceScope trace_scope(ActiveProfileTrace(),
|
||||
async ? "etc2 gather" : "etc2 decode", "texture");
|
||||
// Mapped Vulkan memory is uncached or write-combined, which makes the
|
||||
// decoder's scattered byte reads and writes tens of times slower than on
|
||||
// heap memory. Compressed bytes are streamed into a heap scratch buffer,
|
||||
@@ -1377,89 +1780,121 @@ void VulkanEtc2Emulation::PrepareSubmit(const VkCommandBuffer* command_buffers,
|
||||
static_cast<std::int64_t>(compressed_total));
|
||||
trace_scope.Arg("decoded_bytes", static_cast<std::int64_t>(decoded_total));
|
||||
|
||||
std::lock_guard<std::mutex> scratch_lock(state_->scratch_mutex);
|
||||
std::vector<std::uint8_t>& scratch_source = state_->scratch_source;
|
||||
std::vector<std::uint8_t>& scratch_decoded = state_->scratch_decoded;
|
||||
if (scratch_source.size() < compressed_total) {
|
||||
scratch_source.resize(compressed_total);
|
||||
std::unique_ptr<DecodeBatch> batch;
|
||||
if (async) {
|
||||
batch = state_->AcquireBatch();
|
||||
batch->device = batch_device;
|
||||
}
|
||||
if (scratch_decoded.size() < decoded_total) {
|
||||
scratch_decoded.resize(decoded_total);
|
||||
// Owned copies, so the decode never reads the caller's stack.
|
||||
std::vector<PendingUpload> uploads;
|
||||
uploads.reserve(copied_uploads.size());
|
||||
for (const PendingUpload* upload : copied_uploads) {
|
||||
uploads.push_back(*upload);
|
||||
}
|
||||
std::vector<EtcDecodeJob> jobs;
|
||||
VkDeviceSize compressed_offset = 0;
|
||||
VkDeviceSize decoded_offset = 0;
|
||||
for (std::size_t index = 0; index < copies.size(); ++index) {
|
||||
const Copy& copy = copies[index];
|
||||
const PendingUpload& upload = *copied_uploads[index];
|
||||
std::uint8_t* source = scratch_source.data() + compressed_offset;
|
||||
std::uint8_t* decoded = scratch_decoded.data() + decoded_offset;
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
if (upload.source_span == copy.compressed) {
|
||||
std::memcpy(source, copy.source, copy.compressed);
|
||||
} else {
|
||||
// Padded rows: gather the covered blocks into the packed layout the
|
||||
// decoder expects.
|
||||
const VkDeviceSize block_rows =
|
||||
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
||||
const VkDeviceSize packed_row = compressed_layer / block_rows;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
for (VkDeviceSize row = 0; row < block_rows; ++row) {
|
||||
std::memcpy(source + layer * compressed_layer + row * packed_row,
|
||||
copy.source + layer * upload.source_layer_stride +
|
||||
row * upload.source_row_stride,
|
||||
packed_row);
|
||||
}
|
||||
std::vector<const std::uint8_t*> sources;
|
||||
sources.reserve(copies.size());
|
||||
for (const Copy& copy : copies) {
|
||||
sources.push_back(copy.source);
|
||||
}
|
||||
|
||||
// Reading the application's staging means reading write-combined memory,
|
||||
// which is slow enough to show in a frame. When nothing had to be mapped
|
||||
// for this batch, the application's own mapping stays valid until the copy
|
||||
// completes, so the gather moves to the dispatcher with the decode and the
|
||||
// submitting thread keeps only the bookkeeping.
|
||||
const bool defer_gather = async && temporary.empty();
|
||||
|
||||
if (!async) {
|
||||
std::lock_guard<std::mutex> scratch_lock(state_->scratch_mutex);
|
||||
state_->scratch_source.EnsureSize(compressed_total);
|
||||
if (state_->scratch_decoded.size() < decoded_total) {
|
||||
state_->scratch_decoded.resize(decoded_total);
|
||||
}
|
||||
GatherCompressed(uploads, sources, state_->scratch_source.data());
|
||||
for (const auto& [memory, mapping] : temporary) {
|
||||
if (mapping.dev->unmap_memory != nullptr) {
|
||||
mapping.dev->unmap_memory(mapping.dev->device, memory);
|
||||
}
|
||||
}
|
||||
const VkDeviceSize decoded_layer = upload.decoded / upload.layers;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
EtcDecodeJob job;
|
||||
job.format = upload.format;
|
||||
job.source = source + layer * compressed_layer;
|
||||
job.source_bytes = compressed_layer;
|
||||
job.width = upload.width;
|
||||
job.height = upload.height;
|
||||
job.destination = decoded + layer * decoded_layer;
|
||||
job.destination_bytes = decoded_layer;
|
||||
jobs.push_back(job);
|
||||
}
|
||||
compressed_offset += copy.compressed;
|
||||
decoded_offset += copy.decoded;
|
||||
if (ShouldLog(&g_decode_logs, 4)) {
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] ETC2 upload decoded %ux%u layers=%u\n",
|
||||
upload.width, upload.height, upload.layers);
|
||||
state_->DecodeAndEmit(uploads, state_->scratch_source.data(),
|
||||
state_->scratch_decoded.data());
|
||||
return {};
|
||||
}
|
||||
|
||||
batch->source_bytes = compressed_total;
|
||||
batch->decoded_bytes = decoded_total;
|
||||
if (defer_gather) {
|
||||
batch->deferred_sources = std::move(sources);
|
||||
} else {
|
||||
batch->source.EnsureSize(compressed_total);
|
||||
GatherCompressed(uploads, sources, batch->source.data());
|
||||
for (const auto& [memory, mapping] : temporary) {
|
||||
if (mapping.dev->unmap_memory != nullptr) {
|
||||
mapping.dev->unmap_memory(mapping.dev->device, memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const auto& [memory, mapping] : temporary) {
|
||||
if (mapping.dev->unmap_memory != nullptr) {
|
||||
mapping.dev->unmap_memory(mapping.dev->device, memory);
|
||||
|
||||
batch->uploads = std::move(uploads);
|
||||
// The staging these uploads write into must outlive the decode, so the
|
||||
// command buffers that own it remember the ticket. The batch is queued
|
||||
// while the state lock is held, so a concurrent ReleaseCommandBuffer cannot
|
||||
// read a stale ticket and recycle staging the dispatcher is still writing.
|
||||
// Tickets only ever go up, so a record keeps the highest one it has seen.
|
||||
std::uint64_t ticket = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
ticket = state_->Enqueue(std::move(batch));
|
||||
const auto remember = [this, ticket](VkCommandBuffer command_buffer) {
|
||||
const auto record = state_->commands.find(command_buffer);
|
||||
if (record != state_->commands.end()) {
|
||||
record->second.last_ticket =
|
||||
std::max(record->second.last_ticket, ticket);
|
||||
}
|
||||
};
|
||||
for (std::uint32_t index = 0; index < count; ++index) {
|
||||
const auto record = state_->commands.find(command_buffers[index]);
|
||||
if (record == state_->commands.end()) {
|
||||
continue;
|
||||
}
|
||||
record->second.last_ticket =
|
||||
std::max(record->second.last_ticket, ticket);
|
||||
for (VkCommandBuffer secondary : record->second.secondaries) {
|
||||
remember(secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
DecodeEtcJobs(jobs.data(), jobs.size(), DecodeWorkerCount());
|
||||
for (const EtcDecodeJob& job : jobs) {
|
||||
if (!job.ok) {
|
||||
LogFailure("could not decode an upload layer");
|
||||
}
|
||||
}
|
||||
compressed_offset = 0;
|
||||
decoded_offset = 0;
|
||||
std::vector<State::EmitJob> emit_jobs;
|
||||
emit_jobs.reserve(copied_uploads.size());
|
||||
for (const PendingUpload* upload : copied_uploads) {
|
||||
emit_jobs.push_back({upload, scratch_source.data() + compressed_offset,
|
||||
scratch_decoded.data() + decoded_offset});
|
||||
compressed_offset += upload->compressed;
|
||||
decoded_offset += upload->decoded;
|
||||
}
|
||||
state_->EmitBatch(std::move(emit_jobs), DecodeWorkerCount());
|
||||
trace_scope.Arg("ticket", static_cast<std::int64_t>(ticket));
|
||||
return {batch_dev->timeline, ticket};
|
||||
}
|
||||
|
||||
void VulkanEtc2Emulation::ReleaseCommandBuffer(VkCommandBuffer command_buffer) {
|
||||
if (!state_->has_commands.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
std::uint64_t ticket = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
const auto record = state_->commands.find(command_buffer);
|
||||
if (record == state_->commands.end()) {
|
||||
return;
|
||||
}
|
||||
ticket = record->second.last_ticket;
|
||||
}
|
||||
// Waited on without holding the state lock, which the decode itself takes.
|
||||
// Only waits long enough to matter are traced; this runs for every
|
||||
// command buffer the application resets.
|
||||
ChromeTraceWriter* const trace = ActiveProfileTrace();
|
||||
const std::uint64_t wait_start_ns = trace != nullptr ? TraceClockNanos() : 0;
|
||||
state_->WaitForTicket(ticket);
|
||||
if (trace != nullptr) {
|
||||
const std::uint64_t wait_end_ns = TraceClockNanos();
|
||||
if (wait_end_ns - wait_start_ns >= 100'000) {
|
||||
trace->Slice("etc2 release wait", "texture", wait_start_ns, wait_end_ns,
|
||||
{{"ticket", static_cast<std::int64_t>(ticket)}});
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Staging> doomed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
|
||||
+5
-1
@@ -1650,9 +1650,13 @@ jlong ParseLocalStorageUserIdFromEnv() {
|
||||
if (!value || value[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
errno = 0;
|
||||
char* end = nullptr;
|
||||
long long parsed = std::strtoll(value, &end, 10);
|
||||
return end == value ? 0 : static_cast<jlong>(parsed);
|
||||
if (errno == ERANGE || end == value || *end != '\0') {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<jlong>(parsed);
|
||||
}
|
||||
|
||||
jlong CurrentLocalStorageUserLocked() {
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/prctl.h>
|
||||
#if defined(__x86_64__)
|
||||
#include <asm/prctl.h>
|
||||
#endif
|
||||
#include <sys/syscall.h>
|
||||
#include <vector>
|
||||
|
||||
@@ -50,6 +52,7 @@
|
||||
#include "compat/bionic_socket_runtime.h"
|
||||
#include "compat/build_profile.h"
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "compat/host_abi_experiment.h"
|
||||
#include "compat/host_abi_profile.h"
|
||||
#include "compat/host_allocator_bridge.h"
|
||||
@@ -1046,6 +1049,15 @@ void PumpRobloxMainThreadMessagesOnce() {
|
||||
mocktail::graphics::ChromeTraceWriter* trace =
|
||||
hooks.active != nullptr ? hooks.active() : nullptr;
|
||||
const std::uint64_t pump_start_ns = trace != nullptr ? hooks.clock() : 0;
|
||||
// CPU time separates a step that computes from one that waits.
|
||||
const auto thread_cpu_ns = [] {
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now);
|
||||
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
|
||||
static_cast<std::uint64_t>(now.tv_nsec);
|
||||
};
|
||||
const std::uint64_t pump_start_cpu_ns =
|
||||
trace != nullptr ? thread_cpu_ns() : 0;
|
||||
g_native_call_messages_from_main_thread(
|
||||
env, g_native_gl_class_for_main_thread);
|
||||
if (trace != nullptr) {
|
||||
@@ -1053,8 +1065,11 @@ void PumpRobloxMainThreadMessagesOnce() {
|
||||
// calls that ran a message are recorded.
|
||||
const std::uint64_t pump_end_ns = hooks.clock();
|
||||
if (pump_end_ns - pump_start_ns >= 20'000) {
|
||||
const mocktail::graphics::TraceArg args[] = {
|
||||
{"cpu_us", static_cast<std::int64_t>(
|
||||
(thread_cpu_ns() - pump_start_cpu_ns) / 1000)}};
|
||||
hooks.slice(trace, "nativeCallMessagesFromMainThread", "pump",
|
||||
pump_start_ns, pump_end_ns, nullptr, 0);
|
||||
pump_start_ns, pump_end_ns, args, 1);
|
||||
}
|
||||
}
|
||||
if (__builtin_expect(trace_pump, 0)) {
|
||||
@@ -1096,7 +1111,7 @@ struct BionicAddrInfo {
|
||||
};
|
||||
|
||||
static_assert(sizeof(BionicAddrInfo) == 48,
|
||||
"unexpected x86_64 bionic addrinfo size");
|
||||
"unexpected bionic addrinfo size");
|
||||
|
||||
bool DnsTraceEnabled() {
|
||||
return IsEnabled("MOCKTAIL_DNS_TRACE");
|
||||
@@ -1461,15 +1476,31 @@ bool IsUnsafeSoftTimeoutModule(void* rip) {
|
||||
void JniOnLoadTimeoutAlarm(int, siginfo_t* info, void* context) {
|
||||
static_cast<void>(info);
|
||||
auto* uc = static_cast<ucontext_t*>(context);
|
||||
#if defined(__x86_64__)
|
||||
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RIP]) : 0;
|
||||
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RSP]) : 0;
|
||||
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RBP]) : 0;
|
||||
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RAX]) : 0;
|
||||
const char* regs_format =
|
||||
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
|
||||
"RAX=0x%016llx\n";
|
||||
#elif defined(__aarch64__)
|
||||
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.pc) : 0;
|
||||
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.sp) : 0;
|
||||
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[29]) : 0;
|
||||
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[0]) : 0;
|
||||
const char* regs_format =
|
||||
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
|
||||
"X0=0x%016llx\n";
|
||||
#else
|
||||
uintptr_t rip = 0, rsp = 0, rbp = 0, rax = 0;
|
||||
const char* regs_format =
|
||||
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
|
||||
"X0=0x%016llx\n";
|
||||
#endif
|
||||
char regs_msg[192];
|
||||
int len = std::snprintf(
|
||||
regs_msg, sizeof(regs_msg),
|
||||
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
|
||||
"RAX=0x%016llx\n",
|
||||
regs_msg, sizeof(regs_msg), regs_format,
|
||||
static_cast<unsigned long long>(rip), static_cast<unsigned long long>(rsp),
|
||||
static_cast<unsigned long long>(rbp), static_cast<unsigned long long>(rax));
|
||||
write(2, regs_msg, static_cast<size_t>(len));
|
||||
@@ -2517,10 +2548,12 @@ jobject BuildDeviceParams(JNIEnv* env) {
|
||||
GetEnvString("MOCKTAIL_DEVICE_NAME", "Mocktail Linux");
|
||||
const std::string manufacturer =
|
||||
GetEnvString("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
|
||||
const std::string device_sku =
|
||||
GetEnvString("MOCKTAIL_DEVICE_SKU", "mocktail-x86_64");
|
||||
const std::string device_sku = GetEnvString(
|
||||
"MOCKTAIL_DEVICE_SKU",
|
||||
("mocktail-" + std::string(mocktail::compat::kGuestCpuName)).c_str());
|
||||
const std::string soc_model =
|
||||
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL", "x86_64");
|
||||
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL",
|
||||
std::string(mocktail::compat::kGuestCpuName).c_str());
|
||||
SetStringField(env, params, "osVersion", "33");
|
||||
SetStringField(env, params, "deviceName", device_name.c_str());
|
||||
const std::string app_version =
|
||||
@@ -4718,12 +4751,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
|
||||
// Roblox internal threads trigger SI_KERNEL traps from CET shadow-stack
|
||||
// return mismatches. Unsupported kernels ignore this request.
|
||||
#if defined(__x86_64__)
|
||||
{
|
||||
long r = syscall(SYS_arch_prctl, ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK);
|
||||
if (r == 0) {
|
||||
std::cout << " [cet] shadow-stack (SHSTK) disabled\n";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (build_profile.allow_legacy_binary_patches) {
|
||||
std::cerr
|
||||
@@ -5643,6 +5678,27 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
reinterpret_cast<void*>(mocktail_pthread_spin_trylock));
|
||||
linker::RegisterSymbol("pthread_spin_unlock",
|
||||
reinterpret_cast<void*>(mocktail_pthread_spin_unlock));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_init",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_init));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_destroy",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_destroy));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_setstacksize",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_setstacksize));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_setdetachstate",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_setdetachstate));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_setschedparam",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_setschedparam));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_getattr_np",
|
||||
reinterpret_cast<void*>(mocktail_pthread_getattr_np));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_getstack",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_getstack));
|
||||
linker::RegisterSymbol("pthread_barrier_init",
|
||||
reinterpret_cast<void*>(mocktail_pthread_barrier_init));
|
||||
linker::RegisterSymbol(
|
||||
@@ -5762,7 +5818,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
|
||||
if (roblox_handle == nullptr) {
|
||||
std::cerr << "\n[FATAL] Could not load '" << library_path << "'.\n"
|
||||
<< " Extract lib/x86_64/libroblox.so from a Roblox APK and\n"
|
||||
<< " Extract lib/" << compat::kGuestAbi
|
||||
<< "/libroblox.so from a Roblox APK and\n"
|
||||
<< " place it at the path above (or set ROBLOX_LIB_PATH).\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
@@ -6665,7 +6722,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
mocktail::runtime::RobloxExperienceSurfaceProvider{},
|
||||
discord_rpc.observer(),
|
||||
dependencies.clear_persisted_web_view_cookie(),
|
||||
runtime_config.microphone_enabled());
|
||||
runtime_config.microphone_enabled(),
|
||||
platform_web_symbols.account);
|
||||
const mocktail::Status platform_protocol_status =
|
||||
experience_composition->InitializePlatformProtocols();
|
||||
if (!platform_protocol_status.ok()) {
|
||||
|
||||
@@ -61,24 +61,24 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
destination->payload = MouseMotionEvent{
|
||||
source.motion.x, source.motion.y, source.motion.xrel,
|
||||
source.motion.yrel, static_cast<std::uint32_t>(source.motion.state)};
|
||||
source.motion.x, source.motion.y,
|
||||
source.motion.xrel, source.motion.yrel,
|
||||
static_cast<std::uint32_t>(source.motion.state),
|
||||
SDL_GetWindowRelativeMouseMode(window)};
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
destination->payload = MouseButtonEvent{
|
||||
source.button.down, source.button.button, source.button.clicks,
|
||||
source.button.x, source.button.y};
|
||||
source.button.down, source.button.button, source.button.clicks,
|
||||
source.button.x, source.button.y,
|
||||
SDL_GetWindowRelativeMouseMode(window)};
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_WHEEL: {
|
||||
float delta_x = source.wheel.x;
|
||||
float delta_y = source.wheel.y;
|
||||
if (source.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) {
|
||||
delta_x = -delta_x;
|
||||
delta_y = -delta_y;
|
||||
}
|
||||
// SDL already applies the host's natural-scrolling preference to x/y.
|
||||
// Undoing SDL_MOUSEWHEEL_FLIPPED here would ignore the OS setting.
|
||||
destination->payload = MouseWheelEvent{
|
||||
delta_x, delta_y, source.wheel.mouse_x, source.wheel.mouse_y};
|
||||
source.wheel.x, source.wheel.y, source.wheel.mouse_x,
|
||||
source.wheel.mouse_y, SDL_GetWindowRelativeMouseMode(window)};
|
||||
return true;
|
||||
}
|
||||
case SDL_EVENT_FINGER_DOWN:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
#include "runtime/roblox_launch_uri.h"
|
||||
|
||||
namespace mocktail {
|
||||
@@ -262,11 +263,12 @@ std::string CommandLineUsage(const std::string& program_name) {
|
||||
usage
|
||||
<< "Usage: " << (program_name.empty() ? "mocktail" : program_name)
|
||||
<< " [options] [roblox-uri]\n\n"
|
||||
<< "Normal startup downloads and activates a verified managed x86_64 "
|
||||
"Roblox payload automatically.\n\n"
|
||||
<< "Normal startup downloads and activates a verified managed "
|
||||
<< compat::kGuestAbi
|
||||
<< " Roblox payload automatically.\n\n"
|
||||
<< "Options:\n"
|
||||
<< " --roblox-lib <path> Development override for a specific "
|
||||
"x86_64 libroblox.so\n"
|
||||
<< compat::kGuestAbi << " libroblox.so\n"
|
||||
<< " --headless Run without creating an SDL window\n"
|
||||
<< " --windowed Force windowed startup (default)\n"
|
||||
<< " --graphics <backend> direct-vulkan | opengl | system | "
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "runtime/engine_pump_rest.h"
|
||||
|
||||
#include <cpuid.h>
|
||||
#include <immintrin.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <time.h>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
#include <cpuid.h>
|
||||
#include <immintrin.h>
|
||||
#include <x86intrin.h>
|
||||
#endif
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -23,11 +26,20 @@ uint64_t NowNs() {
|
||||
// The kernel caps one TPAUSE at umwait_control/max_time TSC cycles (100000 by
|
||||
// default), so the wait is a loop of short pauses up to the deadline. Control
|
||||
// bit 0 clear selects C0.2, the deeper of the two light states.
|
||||
#if defined(__x86_64__)
|
||||
__attribute__((target("waitpkg"))) void TpauseUntil(uint64_t deadline_ns) {
|
||||
while (NowNs() < deadline_ns) {
|
||||
_tpause(0, __rdtsc() + 100000ULL);
|
||||
}
|
||||
}
|
||||
#else
|
||||
// TPAUSE is x86-only. CpuHasWaitPkg() is false on other hosts, so kTpause is
|
||||
// never chosen there; this waits out the deadline if it is forced anyway.
|
||||
void TpauseUntil(uint64_t deadline_ns) {
|
||||
while (NowNs() < deadline_ns) {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void LowerTimerSlackOnce() {
|
||||
// Only the main thread rests, so the slack is set once per process.
|
||||
@@ -88,11 +100,15 @@ bool CpuGovernorIsPerformance(const char* path) {
|
||||
}
|
||||
|
||||
bool CpuHasWaitPkg() {
|
||||
#if defined(__x86_64__)
|
||||
unsigned eax = 0, ebx = 0, ecx = 0, edx = 0;
|
||||
if (__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx) == 0) {
|
||||
return false;
|
||||
}
|
||||
return (ecx & (1u << 5)) != 0;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
EnginePumpRestMode ActiveEnginePumpRestMode() {
|
||||
|
||||
@@ -29,12 +29,21 @@ constexpr const char* kIcdDirectories[] = {
|
||||
"/usr/local/share/vulkan/icd.d",
|
||||
};
|
||||
|
||||
#if defined(__aarch64__)
|
||||
constexpr char kNativeArchitecture[] = "aarch64";
|
||||
|
||||
constexpr const char* kForeignArchitectures[] = {
|
||||
"i686", "i586", "i486", "i386", "x86_64", "x86.",
|
||||
"amd64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
|
||||
};
|
||||
#else
|
||||
constexpr char kNativeArchitecture[] = "x86_64";
|
||||
|
||||
constexpr const char* kForeignArchitectures[] = {
|
||||
"i686", "i586", "i486", "i386", "x86.", "aarch64",
|
||||
"arm64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
|
||||
};
|
||||
#endif
|
||||
|
||||
struct HostGpus {
|
||||
bool intel = false;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
|
||||
#include <array>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <iterator>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
constexpr char kAccountProtocolClass[] =
|
||||
"com/roblox/universalapp/account/JNIAccountProtocol";
|
||||
// Serialized name of the APK status enum for a missing Play token provider.
|
||||
constexpr char kTokenProviderUninitialized[] = "TOKEN_PROVIDER_UNINITIALIZED";
|
||||
constexpr std::size_t kMaximumNameBytes = 128;
|
||||
|
||||
enum Method : std::size_t { kDeviceIntegrityAvailable, kGetIntegrityToken };
|
||||
constexpr std::size_t kMethodCount = 2;
|
||||
constexpr const char* kMethodLabels[kMethodCount] = {"DeviceIntegrityAvailable",
|
||||
"GetIntegrityToken"};
|
||||
|
||||
Status Unavailable(const char* message) {
|
||||
return Status::Error(StatusCode::kUnavailable, message);
|
||||
}
|
||||
|
||||
Status CheckJni(JNIEnv* env) {
|
||||
if (!env->ExceptionCheck()) return Status::Ok();
|
||||
env->ExceptionClear();
|
||||
return Unavailable("AccountProtocol JNI operation failed");
|
||||
}
|
||||
|
||||
// Copies a non-empty, bounded engine-provided name.
|
||||
bool ReadName(JNIEnv* env, jstring value, std::string* name) {
|
||||
if (!value) return false;
|
||||
const jsize size = env->GetStringUTFLength(value);
|
||||
const char* bytes = env->GetStringUTFChars(value, nullptr);
|
||||
const bool valid = bytes && size > 0 &&
|
||||
static_cast<std::size_t>(size) <= kMaximumNameBytes;
|
||||
if (valid) name->assign(bytes, static_cast<std::size_t>(size));
|
||||
if (bytes) env->ReleaseStringUTFChars(value, bytes);
|
||||
return valid;
|
||||
}
|
||||
|
||||
// Pins a valid engine-provided name as a global reference.
|
||||
jstring RetainName(JNIEnv* env, jstring value) {
|
||||
std::string ignored;
|
||||
return ReadName(env, value, &ignored)
|
||||
? static_cast<jstring>(env->NewGlobalRef(value))
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct RobloxAccountBridge::State {
|
||||
struct Endpoint {
|
||||
std::shared_ptr<State> state;
|
||||
Method method;
|
||||
};
|
||||
struct Registration {
|
||||
jobject handler = nullptr;
|
||||
jstring method = nullptr;
|
||||
bool registered = false;
|
||||
};
|
||||
|
||||
jobject bus = nullptr;
|
||||
jstring protocol = nullptr;
|
||||
std::string support_key;
|
||||
std::string token_key;
|
||||
std::string result_key;
|
||||
std::array<Registration, kMethodCount> registrations;
|
||||
std::mutex mutex;
|
||||
std::condition_variable drained;
|
||||
bool accepting = true;
|
||||
std::size_t in_flight = 0;
|
||||
|
||||
// The request payload is never parsed or logged: both answers are fixed.
|
||||
static std::string Answer(void* context, JNIEnv*, jstring) {
|
||||
const auto* endpoint = static_cast<Endpoint*>(context);
|
||||
const auto state = endpoint->state;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (!state->accepting) return {};
|
||||
++state->in_flight;
|
||||
}
|
||||
struct Finish {
|
||||
std::shared_ptr<State> state;
|
||||
~Finish() {
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
--state->in_flight;
|
||||
state->drained.notify_all();
|
||||
}
|
||||
} finish{state};
|
||||
|
||||
nlohmann::json body;
|
||||
const char* result = nullptr;
|
||||
if (endpoint->method == kDeviceIntegrityAvailable) {
|
||||
body = {{state->support_key, false}};
|
||||
result = "unsupported";
|
||||
} else {
|
||||
body = {{state->token_key, ""},
|
||||
{state->result_key, kTokenProviderUninitialized}};
|
||||
result = kTokenProviderUninitialized;
|
||||
}
|
||||
std::fprintf(stderr, " [account] AccountProtocol method=%s result=%s\n",
|
||||
kMethodLabels[endpoint->method], result);
|
||||
return body.dump();
|
||||
}
|
||||
};
|
||||
|
||||
RobloxAccountBridge::RobloxAccountBridge(JniEnvironmentProvider environment,
|
||||
RobloxAccountProtocolSymbols symbols,
|
||||
RobloxAccountProtocolObjects objects)
|
||||
: environment_(environment), symbols_(symbols), objects_(objects) {}
|
||||
|
||||
RobloxAccountBridge::~RobloxAccountBridge() { (void)Shutdown(); }
|
||||
|
||||
Status RobloxAccountBridge::Initialize() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
if (state_) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition,
|
||||
"AccountProtocol is already initialized");
|
||||
}
|
||||
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
|
||||
return Unavailable("AccountProtocol prerequisites are incomplete");
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
|
||||
jclass protocol_class = env->FindClass(kAccountProtocolClass);
|
||||
if (!protocol_class) {
|
||||
(void)CheckJni(env);
|
||||
return Unavailable("JNIAccountProtocol class is unavailable");
|
||||
}
|
||||
const jstring protocol = symbols_.get_protocol_name(env, protocol_class);
|
||||
const jstring methods[kMethodCount] = {
|
||||
symbols_.get_device_integrity_available_method_name(env, protocol_class),
|
||||
symbols_.get_integrity_token_method_name(env, protocol_class)};
|
||||
const jstring keys[] = {symbols_.get_support_key(env, protocol_class),
|
||||
symbols_.get_token_key(env, protocol_class),
|
||||
symbols_.get_result_key(env, protocol_class)};
|
||||
env->DeleteLocalRef(protocol_class);
|
||||
|
||||
state_ = std::make_shared<State>();
|
||||
std::string* const key_targets[] = {&state_->support_key,
|
||||
&state_->token_key, &state_->result_key};
|
||||
bool names_valid = true;
|
||||
for (std::size_t i = 0; i < std::size(keys); ++i) {
|
||||
names_valid = ReadName(env, keys[i], key_targets[i]) && names_valid;
|
||||
if (keys[i]) env->DeleteLocalRef(keys[i]);
|
||||
}
|
||||
state_->protocol = RetainName(env, protocol);
|
||||
if (protocol) env->DeleteLocalRef(protocol);
|
||||
for (std::size_t i = 0; i < kMethodCount; ++i) {
|
||||
state_->registrations[i].method = RetainName(env, methods[i]);
|
||||
names_valid = state_->registrations[i].method && names_valid;
|
||||
if (methods[i]) env->DeleteLocalRef(methods[i]);
|
||||
}
|
||||
state_->bus = env->NewGlobalRef(objects_.message_bus);
|
||||
auto fail = [this](Status failure) {
|
||||
(void)ShutdownLocked();
|
||||
return failure;
|
||||
};
|
||||
if (!CheckJni(env).ok() || !names_valid || !state_->protocol ||
|
||||
!state_->bus) {
|
||||
return fail(Unavailable("AccountProtocol names are unavailable"));
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < kMethodCount; ++i) {
|
||||
auto& entry = state_->registrations[i];
|
||||
auto endpoint = std::make_shared<State::Endpoint>(
|
||||
State::Endpoint{state_, static_cast<Method>(i)});
|
||||
jobject handler = objects_.create_request_handler(objects_.context,
|
||||
endpoint, &State::Answer);
|
||||
if (handler) {
|
||||
entry.handler = env->NewGlobalRef(handler);
|
||||
if (!entry.handler) {
|
||||
objects_.clear_request_handler(objects_.context, handler);
|
||||
}
|
||||
env->DeleteLocalRef(handler);
|
||||
}
|
||||
if (!entry.handler || !CheckJni(env).ok()) {
|
||||
return fail(Unavailable("could not create AccountProtocol handler"));
|
||||
}
|
||||
entry.registered = true;
|
||||
symbols_.set_request_handler_raw(env, state_->bus, state_->protocol,
|
||||
entry.method, entry.handler);
|
||||
status = CheckJni(env);
|
||||
if (!status.ok()) return fail(status);
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [account] AccountProtocol ready device_integrity=unsupported\n");
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxAccountBridge::Shutdown() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
return ShutdownLocked();
|
||||
}
|
||||
|
||||
Status RobloxAccountBridge::ShutdownLocked() {
|
||||
if (!state_) return Status::Ok();
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state_->mutex);
|
||||
state_->accepting = false;
|
||||
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
|
||||
// Clear any exception from partially failed initialization before cleanup.
|
||||
const Status pending = CheckJni(env);
|
||||
if (!pending.ok()) status = pending;
|
||||
for (auto& entry : state_->registrations) {
|
||||
if (entry.registered) {
|
||||
symbols_.clear_request_handler(env, state_->bus, state_->protocol,
|
||||
entry.method);
|
||||
const Status cleared = CheckJni(env);
|
||||
if (!cleared.ok()) status = cleared;
|
||||
}
|
||||
if (entry.handler) {
|
||||
objects_.clear_request_handler(objects_.context, entry.handler);
|
||||
env->DeleteGlobalRef(entry.handler);
|
||||
}
|
||||
if (entry.method) env->DeleteGlobalRef(entry.method);
|
||||
entry = {};
|
||||
}
|
||||
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
|
||||
if (state_->bus) env->DeleteGlobalRef(state_->bus);
|
||||
state_.reset();
|
||||
return status;
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
#include "runtime/external_launch_broker.h"
|
||||
#include "runtime/roblox_launch_uri.h"
|
||||
#include "runtime/webview_helper_launcher.h"
|
||||
#include "window/window.h"
|
||||
|
||||
@@ -21,7 +22,13 @@ namespace {
|
||||
constexpr size_t kMaxPendingLaunchRequests = 8;
|
||||
// libroblox's static TLS requires the proven 16 MiB guest-thread stack floor.
|
||||
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
|
||||
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000};
|
||||
// Covers helper exec through window creation, including cold Flatpak starts.
|
||||
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000};
|
||||
// Roblox closes a solved WebViewProtocol challenge itself with closeWindow,
|
||||
// as on Android. The host closes it only if Roblox has not done so within
|
||||
// this period. Captcha windows close at once instead (see
|
||||
// RobloxWebViewOpenRequest::close_when_challenge_solved).
|
||||
constexpr std::chrono::milliseconds kSolvedChallengeCloseGrace{3000};
|
||||
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
|
||||
|
||||
Status Invalid(std::string message) {
|
||||
@@ -158,24 +165,33 @@ Status LaunchRobloxWebSurface(
|
||||
}
|
||||
std::fprintf(stderr, " [%s] opening validated Roblox web surface\n",
|
||||
transport);
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const WebViewHelperLaunchResult launched =
|
||||
LaunchWebViewHelper(helper, url, std::move(exit_observer));
|
||||
if (!launched) {
|
||||
return Unavailable("could not display Roblox web surface: " +
|
||||
launched.error);
|
||||
}
|
||||
if (launched.process == nullptr ||
|
||||
!launched.process->WaitUntilReady(kWebSurfaceReadyTimeout)) {
|
||||
const bool ready = launched.process != nullptr &&
|
||||
launched.process->WaitUntilReady(kWebSurfaceReadyTimeout);
|
||||
const long long elapsed_ms =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - started)
|
||||
.count();
|
||||
if (!ready) {
|
||||
if (launched.process != nullptr) {
|
||||
(void)launched.process->RequestClose();
|
||||
}
|
||||
return Unavailable(
|
||||
"Roblox web surface did not become ready before the deadline");
|
||||
"Roblox web surface did not become ready before the deadline (" +
|
||||
std::to_string(elapsed_ms) + " ms of " +
|
||||
std::to_string(kWebSurfaceReadyTimeout.count()) + " ms)");
|
||||
}
|
||||
if (process != nullptr) {
|
||||
*process = launched.process;
|
||||
}
|
||||
std::fprintf(stderr, " [%s] Roblox web surface launched\n", transport);
|
||||
std::fprintf(stderr, " [%s] Roblox web surface launched in %lld ms\n",
|
||||
transport, elapsed_ms);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
@@ -299,12 +315,14 @@ RobloxExperienceComposition::RobloxExperienceComposition(
|
||||
const SecureRobloxCredential* initial_web_view_credential,
|
||||
RobloxExperienceSurfaceProvider surface_provider,
|
||||
RobloxExperiencePresenceObserver presence_observer,
|
||||
bool clear_persisted_web_view_cookie, bool microphone_enabled)
|
||||
bool clear_persisted_web_view_cookie, bool microphone_enabled,
|
||||
RobloxAccountProtocolSymbols account_symbols)
|
||||
: environment_(environment),
|
||||
message_bus_symbols_(message_bus_symbols),
|
||||
web_view_symbols_(web_view_symbols),
|
||||
browser_service_symbols_(browser_service_symbols),
|
||||
permissions_symbols_(permissions_symbols),
|
||||
account_symbols_(account_symbols),
|
||||
microphone_enabled_(microphone_enabled),
|
||||
game_symbols_(game_symbols),
|
||||
jni_factory_(jni_factory),
|
||||
@@ -425,11 +443,33 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||
(void)ReleaseGlobalObjects();
|
||||
return status;
|
||||
}
|
||||
// AccountProtocol only shortens a device-integrity login challenge that
|
||||
// cannot succeed here, so its absence or failure never blocks startup.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||
if (account_symbols_.complete()) {
|
||||
account_bridge = std::make_unique<RobloxAccountBridge>(
|
||||
environment_, account_symbols_,
|
||||
RobloxAccountProtocolObjects{web_view_objects.message_bus,
|
||||
jni_factory_.context,
|
||||
jni_factory_.create_request_handler,
|
||||
jni_factory_.clear_request_handler});
|
||||
const Status account_status = account_bridge->Initialize();
|
||||
if (!account_status.ok()) {
|
||||
std::fprintf(stderr, " [account] AccountProtocol unavailable: %s\n",
|
||||
account_status.message().c_str());
|
||||
account_bridge.reset();
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" [account] AccountProtocol entrypoints are missing; "
|
||||
"device-integrity challenges wait for Roblox's timeout\n");
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
web_view_bridge_ = std::move(web_view_bridge);
|
||||
browser_service_bridge_ = std::move(browser_service_bridge);
|
||||
permissions_bridge_ = std::move(permissions_bridge);
|
||||
account_bridge_ = std::move(account_bridge);
|
||||
platform_protocols_initialized_ = true;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
@@ -602,6 +642,8 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
web_surface_process_generation_ = process_generation;
|
||||
web_surface_route_ = route;
|
||||
web_surface_logical_generation_ = logical_generation;
|
||||
web_surface_close_when_challenge_solved_ =
|
||||
presentation.close_when_challenge_solved;
|
||||
web_surface_logical_exit_observer_ = std::move(exit_observer);
|
||||
}
|
||||
if (superseded_observer.valid()) {
|
||||
@@ -614,13 +656,16 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
Status RobloxExperienceComposition::CloseWebSurface(
|
||||
uint64_t expected_logical_generation) {
|
||||
std::lock_guard<std::mutex> operation_lock(web_surface_operation_mutex_);
|
||||
std::shared_ptr<WebViewHelperProcess> process;
|
||||
WebViewHelperExitObserver exit_observer;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (web_surface_route_ == WebSurfaceRoute::kNone) {
|
||||
if (web_surface_route_ == WebSurfaceRoute::kNone ||
|
||||
(expected_logical_generation != 0 &&
|
||||
expected_logical_generation != web_surface_logical_generation_)) {
|
||||
return Status::Ok();
|
||||
}
|
||||
process = std::move(web_surface_process_);
|
||||
@@ -628,6 +673,7 @@ Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
exit_observer = std::move(web_surface_logical_exit_observer_);
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
web_surface_logical_generation_ = 0;
|
||||
web_surface_close_when_challenge_solved_ = false;
|
||||
}
|
||||
// A closed challenge must stop executing. Reusing its hidden document lets
|
||||
// late callbacks from the old attempt reach the next login attempt. The APK
|
||||
@@ -643,6 +689,33 @@ Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::CloseUnclaimedSolvedChallenge() {
|
||||
uint64_t generation = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!solved_challenge_close_deadline_.has_value()) {
|
||||
return Status::Ok();
|
||||
}
|
||||
const bool still_open =
|
||||
web_surface_route_ != WebSurfaceRoute::kNone &&
|
||||
solved_challenge_logical_generation_ == web_surface_logical_generation_;
|
||||
if (still_open &&
|
||||
std::chrono::steady_clock::now() < *solved_challenge_close_deadline_) {
|
||||
return Status::Ok();
|
||||
}
|
||||
solved_challenge_close_deadline_.reset();
|
||||
if (!still_open) {
|
||||
return Status::Ok();
|
||||
}
|
||||
generation = solved_challenge_logical_generation_;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [webview] closing solved challenge window; Roblox did not "
|
||||
"close it within %lld ms\n",
|
||||
static_cast<long long>(kSolvedChallengeCloseGrace.count()));
|
||||
return CloseWebSurface(generation);
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::WebSurfaceExited(void* context) {
|
||||
auto* exit_context = static_cast<WebSurfaceExitContext*>(context);
|
||||
if (exit_context == nullptr || exit_context->target == nullptr ||
|
||||
@@ -668,6 +741,7 @@ void RobloxExperienceComposition::HandleWebSurfaceExit(
|
||||
web_surface_process_generation_ = 0;
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
web_surface_logical_generation_ = 0;
|
||||
web_surface_close_when_challenge_solved_ = false;
|
||||
exit_observer = std::move(web_surface_logical_exit_observer_);
|
||||
}
|
||||
if (exit_observer.valid()) {
|
||||
@@ -693,6 +767,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
|
||||
// host back navigation too.
|
||||
presentation.back_navigation_disabled =
|
||||
request.back_button_visible.has_value() && !*request.back_button_visible;
|
||||
presentation.close_when_challenge_solved =
|
||||
request.close_when_challenge_solved;
|
||||
return composition->OpenWebSurface(request.url, "webview",
|
||||
WebSurfaceRoute::kWebView,
|
||||
std::move(exit_observer), presentation);
|
||||
@@ -951,9 +1027,45 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
|
||||
route = web_surface_route_;
|
||||
web_view_bridge = web_view_bridge_.get();
|
||||
}
|
||||
return event.type == WebViewHelperEventType::kRobloxCookie
|
||||
? AcceptWebViewRobloxCookie(event.payload)
|
||||
: RouteWebSurfaceEvent(route, event, web_view_bridge);
|
||||
if (event.type == WebViewHelperEventType::kRobloxCookie) {
|
||||
return AcceptWebViewRobloxCookie(event.payload);
|
||||
}
|
||||
if (event.type == WebViewHelperEventType::kExecuteRoblox) {
|
||||
// A web Play button's launchGame is launched through the experience
|
||||
// pipeline and closes the surface. It is never forwarded, so Roblox
|
||||
// cannot start a second join from the same command.
|
||||
RobloxExperienceLaunchRequest request;
|
||||
if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() &&
|
||||
Dispatch(request).ok()) {
|
||||
std::fprintf(stderr,
|
||||
"[experience] WebView launchGame queued place_id=%lld\n",
|
||||
static_cast<long long>(request.place_id));
|
||||
return CloseWebSurface();
|
||||
}
|
||||
}
|
||||
const Status status = RouteWebSurfaceEvent(route, event, web_view_bridge);
|
||||
if (!status.ok() || !IsRobloxWebViewChallengeSuccess(event.payload)) {
|
||||
return status;
|
||||
}
|
||||
std::fprintf(stderr, " [webview] solved challenge forwarded to Roblox\n");
|
||||
bool close_now = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (logical_generation != web_surface_logical_generation_) {
|
||||
return status;
|
||||
}
|
||||
close_now = web_surface_close_when_challenge_solved_;
|
||||
if (!close_now) {
|
||||
solved_challenge_close_deadline_ =
|
||||
std::chrono::steady_clock::now() + kSolvedChallengeCloseGrace;
|
||||
solved_challenge_logical_generation_ = logical_generation;
|
||||
}
|
||||
}
|
||||
if (!close_now) {
|
||||
return status;
|
||||
}
|
||||
std::fprintf(stderr, " [webview] closing solved captcha window\n");
|
||||
return CloseWebSurface(logical_generation);
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::DrainPlatformEvents() {
|
||||
@@ -1002,6 +1114,9 @@ Status RobloxExperienceComposition::DrainPlatformEvents() {
|
||||
status = drain_helper(web_surface_process, process_generation,
|
||||
logical_generation);
|
||||
}
|
||||
if (status.ok()) {
|
||||
status = CloseUnclaimedSolvedChallenge();
|
||||
}
|
||||
if (status.ok() && browser_service_bridge != nullptr) {
|
||||
status = browser_service_bridge->DrainOutgoingEvents();
|
||||
}
|
||||
@@ -1242,6 +1357,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
if (active_launch_ != nullptr) {
|
||||
completed_result = active_launch_->result;
|
||||
game_active_ = completed_result.ok();
|
||||
window::SetGameSessionActive(game_active_);
|
||||
active_game_canonical_json_ =
|
||||
completed_result.ok() ? active_launch_->request.canonical_json
|
||||
: std::string();
|
||||
@@ -1279,6 +1395,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
return observed;
|
||||
}
|
||||
game_active_ = false;
|
||||
window::SetGameSessionActive(false);
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
@@ -1454,6 +1571,7 @@ Status RobloxExperienceComposition::LeaveGame() {
|
||||
if (status.ok()) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
game_active_ = false;
|
||||
window::SetGameSessionActive(false);
|
||||
active_game_canonical_json_.clear();
|
||||
}
|
||||
return status;
|
||||
@@ -1540,6 +1658,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge;
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process;
|
||||
jnivm::VM* late_lifecycle_vm = nullptr;
|
||||
{
|
||||
@@ -1552,6 +1671,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
subscribed_ = false;
|
||||
game_active_ = false;
|
||||
window::SetGameSessionActive(false);
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
@@ -1563,6 +1683,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
web_view_bridge = std::move(web_view_bridge_);
|
||||
browser_service_bridge = std::move(browser_service_bridge_);
|
||||
permissions_bridge = std::move(permissions_bridge_);
|
||||
account_bridge = std::move(account_bridge_);
|
||||
web_surface_process = std::move(web_surface_process_);
|
||||
web_surface_logical_exit_observer_ = {};
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
@@ -1579,8 +1700,12 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
if (web_surface_process != nullptr) {
|
||||
(void)web_surface_process->RequestClose();
|
||||
}
|
||||
Status status = permissions_bridge != nullptr ? permissions_bridge->Shutdown()
|
||||
: Status::Ok();
|
||||
Status status =
|
||||
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
|
||||
const Status permissions_status = permissions_bridge != nullptr
|
||||
? permissions_bridge->Shutdown()
|
||||
: Status::Ok();
|
||||
if (status.ok()) status = permissions_status;
|
||||
const Status browser_status = browser_service_bridge != nullptr
|
||||
? browser_service_bridge->Shutdown()
|
||||
: Status::Ok();
|
||||
@@ -1596,6 +1721,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
status = bridge_status;
|
||||
}
|
||||
browser_service_bridge.reset();
|
||||
account_bridge.reset();
|
||||
permissions_bridge.reset();
|
||||
web_view_bridge.reset();
|
||||
bridge.reset();
|
||||
|
||||
@@ -274,14 +274,15 @@ AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode,
|
||||
return {};
|
||||
}
|
||||
|
||||
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button) {
|
||||
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button) {
|
||||
switch (sdl_button) {
|
||||
case SDL_BUTTON_LEFT:
|
||||
return 0;
|
||||
case SDL_BUTTON_RIGHT:
|
||||
return 1;
|
||||
case SDL_BUTTON_MIDDLE:
|
||||
return 3;
|
||||
// MouseButton3 is index 2, not BUTTON_TERTIARY (4) minus one.
|
||||
return 2;
|
||||
case SDL_BUTTON_X1:
|
||||
return 7;
|
||||
case SDL_BUTTON_X2:
|
||||
@@ -550,13 +551,14 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
|
||||
const float delta_x = transform.HostLogicalToGuestX(event.delta_x);
|
||||
const float delta_y = transform.HostLogicalToGuestY(event.delta_y);
|
||||
|
||||
// Relative-mode motion reports zero absolute coordinates and a delta. The
|
||||
// pointer holds its captured position while the delta passes through, so a
|
||||
// long camera turn neither parks the reported position at the viewport edge
|
||||
// The pointer holds its captured position while the delta passes through, so
|
||||
// a long camera turn neither parks the reported position at the viewport edge
|
||||
// nor swallows the reversal. Roblox draws its cursor at the held position
|
||||
// when the capture ends, which matches where the host pointer is warped.
|
||||
if (event.x == 0.0f && event.y == 0.0f &&
|
||||
(delta_x != 0.0f || delta_y != 0.0f)) {
|
||||
// Capture is read from the event, not inferred: only X11 zeroes the absolute
|
||||
// coordinates, while Wayland reports a position that accumulates the deltas.
|
||||
if (event.relative || (event.x == 0.0f && event.y == 0.0f &&
|
||||
(delta_x != 0.0f || delta_y != 0.0f))) {
|
||||
HoldPointerLocked(transform);
|
||||
} else {
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
@@ -585,19 +587,20 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
|
||||
RobloxInputEventKind::kMouseButton,
|
||||
Unsupported("native mouse buttons are unavailable"));
|
||||
}
|
||||
const int32_t android_button = MapSdlMouseButtonToAndroid(event.button);
|
||||
if (android_button < 0) {
|
||||
const int32_t roblox_button = MapSdlMouseButtonToRoblox(event.button);
|
||||
if (roblox_button < 0) {
|
||||
return Result(RobloxInputDispatchState::kIgnoredUnsupported,
|
||||
RobloxInputEventKind::kMouseButton,
|
||||
Unsupported("SDL mouse button has no Android mapping"));
|
||||
Unsupported("SDL mouse button has no Roblox mapping"));
|
||||
}
|
||||
const platform::SurfaceCoordinateTransform transform =
|
||||
CoordinateTransform(snapshot_.viewport);
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
// Button events during pointer capture carry zero coordinates and must not
|
||||
// move the held position.
|
||||
if (event.x > 0.0f || event.y > 0.0f) {
|
||||
// Button events during pointer capture must not move the held position.
|
||||
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
|
||||
// flag decides.
|
||||
if (!event.relative && (event.x > 0.0f || event.y > 0.0f)) {
|
||||
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.x), 0.0f, max_x);
|
||||
mouse_y_ = std::clamp(transform.HostLogicalToGuestY(event.y), 0.0f, max_y);
|
||||
pointer_anchored_ = true;
|
||||
@@ -614,12 +617,12 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
|
||||
}
|
||||
}
|
||||
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
|
||||
event.pressed, android_button);
|
||||
event.pressed, roblox_button);
|
||||
if (status.ok()) {
|
||||
const auto active = std::find(active_mouse_buttons_.begin(),
|
||||
active_mouse_buttons_.end(), android_button);
|
||||
active_mouse_buttons_.end(), roblox_button);
|
||||
if (event.pressed && active == active_mouse_buttons_.end()) {
|
||||
active_mouse_buttons_.push_back(android_button);
|
||||
active_mouse_buttons_.push_back(roblox_button);
|
||||
} else if (!event.pressed && active != active_mouse_buttons_.end()) {
|
||||
active_mouse_buttons_.erase(active);
|
||||
}
|
||||
@@ -638,9 +641,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseWheelLocked(
|
||||
}
|
||||
const platform::SurfaceCoordinateTransform transform =
|
||||
CoordinateTransform(snapshot_.viewport);
|
||||
// Wheel events during pointer capture carry zero coordinates and must not
|
||||
// move the held position.
|
||||
if (event.mouse_x > 0.0f || event.mouse_y > 0.0f) {
|
||||
// Wheel events during pointer capture must not move the held position.
|
||||
// Their coordinates are zeroed on X11 and drifted on Wayland, so the capture
|
||||
// flag decides.
|
||||
if (!event.relative && (event.mouse_x > 0.0f || event.mouse_y > 0.0f)) {
|
||||
const float max_x = std::max(0.0F, transform.guest_width() - 1.0F);
|
||||
const float max_y = std::max(0.0F, transform.guest_height() - 1.0F);
|
||||
mouse_x_ = std::clamp(transform.HostLogicalToGuestX(event.mouse_x), 0.0f,
|
||||
|
||||
@@ -640,5 +640,64 @@ Status ParseRobloxLaunchUri(std::string_view uri,
|
||||
return MakeRequest(fields, request);
|
||||
}
|
||||
|
||||
Status ParseRobloxWebViewLaunchGame(std::string_view command,
|
||||
RobloxExperienceLaunchRequest* request) {
|
||||
if (request == nullptr) {
|
||||
return Invalid("Roblox launchGame output is null");
|
||||
}
|
||||
*request = {};
|
||||
const nlohmann::json document =
|
||||
command.size() <= kMaximumRobloxLaunchUriBytes
|
||||
? nlohmann::json::parse(command, nullptr, false)
|
||||
: nlohmann::json();
|
||||
const auto function_name = document.is_object()
|
||||
? document.find("functionName")
|
||||
: document.end();
|
||||
if (!document.is_object() || function_name == document.end() ||
|
||||
*function_name != "launchGame") {
|
||||
return Status::Error(StatusCode::kUnsupported,
|
||||
"WebView command is not launchGame");
|
||||
}
|
||||
const auto params = document.find("params");
|
||||
if (params == document.end() || !params->is_object()) {
|
||||
return Invalid("Roblox launchGame has no params");
|
||||
}
|
||||
const auto launch = params->find("request");
|
||||
if (launch == params->end() || !launch->is_object()) {
|
||||
return Invalid("Roblox launchGame has no request");
|
||||
}
|
||||
if (launch->size() > kMaximumParameterCount) {
|
||||
return Invalid("Roblox launchGame request has too many fields");
|
||||
}
|
||||
LaunchFields fields;
|
||||
for (const auto& [key, value] : launch->items()) {
|
||||
std::string text;
|
||||
if (value.is_string()) {
|
||||
text = value.get<std::string>();
|
||||
} else if (value.is_number_unsigned()) {
|
||||
text = std::to_string(value.get<uint64_t>());
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (text.size() > kMaximumParameterValueBytes ||
|
||||
ContainsControlBytes(text)) {
|
||||
return Invalid("Roblox launchGame field is invalid");
|
||||
}
|
||||
Status status = AssignField(key == "requestType" ? "request" : key,
|
||||
std::move(text), false, &fields);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
if (fields.request_type.empty()) {
|
||||
return Invalid("Roblox launchGame request type is missing");
|
||||
}
|
||||
Status status = MakeRequest(fields, request);
|
||||
if (!status.ok()) {
|
||||
*request = {};
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -1,7 +1,11 @@
|
||||
#include "runtime/roblox_platform_web_symbols.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/web_view_protocol_contract.h"
|
||||
#include "linker/linker.h"
|
||||
|
||||
namespace mocktail {
|
||||
@@ -14,6 +18,41 @@ Function Resolve(void* library, const char* name) {
|
||||
linker::ResolveSymbol(library, std::string(name)));
|
||||
}
|
||||
|
||||
// Reads the mapped initializer and follows its getter call only when both
|
||||
// match the code contract for this host's architecture. Both reads stay
|
||||
// inside libroblox.so's text segment: the initializer is longer than the scan
|
||||
// window, and the getter address comes from a verified call site.
|
||||
AcquireWebViewProtocolFn FindWebViewProtocolGetter(const void* initializer) {
|
||||
if (initializer == nullptr) return nullptr;
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(initializer);
|
||||
const std::string_view code(static_cast<const char*>(initializer),
|
||||
compat::kWebViewProtocolInitializerScanBytes);
|
||||
#if defined(__x86_64__)
|
||||
const std::optional<std::uintptr_t> getter =
|
||||
compat::FindWebViewProtocolGetterCallX86_64(code, address);
|
||||
const bool getter_matches =
|
||||
getter.has_value() &&
|
||||
compat::HasWebViewProtocolGetterContractX86_64(std::string_view(
|
||||
reinterpret_cast<const char*>(*getter),
|
||||
compat::kWebViewProtocolGetterBytesX86_64));
|
||||
#elif defined(__aarch64__)
|
||||
const std::optional<std::uintptr_t> getter =
|
||||
compat::FindWebViewProtocolGetterCallArm64(code, address);
|
||||
const bool getter_matches =
|
||||
getter.has_value() &&
|
||||
compat::HasWebViewProtocolGetterContractArm64(std::string_view(
|
||||
reinterpret_cast<const char*>(*getter),
|
||||
compat::kWebViewProtocolGetterBytesArm64));
|
||||
#else
|
||||
const std::optional<std::uintptr_t> getter;
|
||||
const bool getter_matches = false;
|
||||
(void)code;
|
||||
(void)address;
|
||||
#endif
|
||||
return getter_matches ? reinterpret_cast<AcquireWebViewProtocolFn>(*getter)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
||||
@@ -46,6 +85,9 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
||||
roblox_library,
|
||||
"Java_com_roblox_protocols_webview_WebViewProtocol_"
|
||||
"initializeAndroidWebViewProtocol");
|
||||
symbols.web_view.acquire_web_view_protocol = FindWebViewProtocolGetter(
|
||||
reinterpret_cast<const void*>(
|
||||
symbols.web_view.initialize_android_web_view_protocol));
|
||||
symbols.web_view.subscribe_raw = subscribe_raw;
|
||||
symbols.web_view.delete_connection = delete_connection;
|
||||
symbols.web_view.set_request_handler_raw =
|
||||
@@ -118,6 +160,24 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
||||
roblox_library,
|
||||
"Java_com_roblox_universalapp_messagebus_MessageBus_"
|
||||
"callResponseHandlerRaw");
|
||||
using AccountStringFn = RobloxAccountProtocolSymbols::GetStringFn;
|
||||
constexpr char kAccountPrefix[] =
|
||||
"Java_com_roblox_universalapp_account_JNIAccountProtocol_";
|
||||
const auto account_getter = [&](const char* name) {
|
||||
return Resolve<AccountStringFn>(roblox_library,
|
||||
(std::string(kAccountPrefix) + name).c_str());
|
||||
};
|
||||
symbols.account.get_protocol_name = account_getter("getProtocolName");
|
||||
symbols.account.get_device_integrity_available_method_name =
|
||||
account_getter("getDeviceIntegrityAvailableMethodName");
|
||||
symbols.account.get_integrity_token_method_name =
|
||||
account_getter("getGetIntegrityTokenMethodName");
|
||||
symbols.account.get_support_key = account_getter("getSupportKey");
|
||||
symbols.account.get_token_key = account_getter("getTokenKey");
|
||||
symbols.account.get_result_key = account_getter("getResultKey");
|
||||
symbols.account.set_request_handler_raw =
|
||||
symbols.web_view.set_request_handler_raw;
|
||||
symbols.account.clear_request_handler = symbols.web_view.clear_request_handler;
|
||||
return symbols;
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ struct TextBytePosition {
|
||||
};
|
||||
|
||||
TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
|
||||
int text_width) {
|
||||
int text_width, bool single_line) {
|
||||
if (layout == nullptr) {
|
||||
return {};
|
||||
}
|
||||
@@ -259,7 +259,11 @@ TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
|
||||
position.y = substring.rect.y;
|
||||
position.height = substring.rect.h;
|
||||
if ((substring.flags & TTF_SUBSTRING_TEXT_END) != 0) {
|
||||
position.x = std::min(text_width, substring.rect.x + substring.rect.w);
|
||||
// Trailing whitespace has no ink, so the final cluster rect stops short of
|
||||
// the laid-out width that a caret at the end of a single line sits behind.
|
||||
position.x = single_line ? text_width
|
||||
: std::min(text_width,
|
||||
substring.rect.x + substring.rect.w);
|
||||
}
|
||||
return position;
|
||||
}
|
||||
@@ -297,6 +301,7 @@ Status RobloxTextSurfaceOverlay::Shutdown() {
|
||||
}
|
||||
state_.Reset();
|
||||
ClearFrameLocked();
|
||||
CloseFontsLocked();
|
||||
viewport_ = {};
|
||||
initialized_ = false;
|
||||
if (owned_active_overlay) {
|
||||
@@ -441,10 +446,6 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
const bool wrapped_layout =
|
||||
presentation.multiline || presentation.text_wrapped;
|
||||
|
||||
if (!TTF_Init()) {
|
||||
SecureClear(&candidate);
|
||||
return RasterizationFailure("unable to initialize SDL3_ttf");
|
||||
}
|
||||
const RobloxTextFontSelection font_selection =
|
||||
ResolveRobloxTextFont(presentation.font);
|
||||
const float native_point_size =
|
||||
@@ -454,23 +455,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
? std::clamp(native_point_size * font_selection.size_scale, 1.0F,
|
||||
512.0F)
|
||||
: std::clamp(static_cast<float>(height) * 0.38F, 12.0F, 28.0F);
|
||||
const std::vector<std::string> font_files = ResolveFontFiles(font_selection);
|
||||
std::vector<TTF_Font*> fonts;
|
||||
fonts.reserve(font_files.size());
|
||||
for (const std::string& file : font_files) {
|
||||
TTF_Font* font = TTF_OpenFont(file.c_str(), point_size);
|
||||
if (font != nullptr) {
|
||||
fonts.push_back(font);
|
||||
}
|
||||
}
|
||||
if (fonts.empty()) {
|
||||
TTF_Quit();
|
||||
if (Status status = OpenFontsLocked(presentation.font, point_size);
|
||||
!status.ok()) {
|
||||
SecureClear(&candidate);
|
||||
return RasterizationFailure("unable to open a Roblox or fallback font");
|
||||
}
|
||||
for (std::size_t index = 1; index < fonts.size(); ++index) {
|
||||
(void)TTF_AddFallbackFont(fonts.front(), fonts[index]);
|
||||
return status;
|
||||
}
|
||||
const std::vector<TTF_Font*>& fonts = fonts_;
|
||||
if (wrapped_layout) {
|
||||
TTF_HorizontalAlignment alignment = TTF_HORIZONTAL_ALIGN_LEFT;
|
||||
if (presentation.x_alignment == 1) {
|
||||
@@ -501,11 +491,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
TextBytePosition selection_end_position;
|
||||
if (layout != nullptr) {
|
||||
(void)TTF_GetTextSize(layout, &text_width, &text_height);
|
||||
caret_position = TextPosition(layout, caret_byte, text_width);
|
||||
selection_begin_position =
|
||||
TextPosition(layout, selection_begin_byte, text_width);
|
||||
caret_position =
|
||||
TextPosition(layout, caret_byte, text_width, !wrapped_layout);
|
||||
selection_begin_position = TextPosition(layout, selection_begin_byte,
|
||||
text_width, !wrapped_layout);
|
||||
selection_end_position =
|
||||
TextPosition(layout, selection_end_byte, text_width);
|
||||
TextPosition(layout, selection_end_byte, text_width, !wrapped_layout);
|
||||
}
|
||||
if (!text.value.empty()) {
|
||||
// SDL_ttf implementations have historically differed on whether fg.a is
|
||||
@@ -624,11 +615,6 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
if (layout != nullptr) {
|
||||
TTF_DestroyText(layout);
|
||||
}
|
||||
TTF_ClearFallbackFonts(fonts.front());
|
||||
for (auto iterator = fonts.rbegin(); iterator != fonts.rend(); ++iterator) {
|
||||
TTF_CloseFont(*iterator);
|
||||
}
|
||||
TTF_Quit();
|
||||
|
||||
if (!status.ok()) {
|
||||
SecureClear(&candidate);
|
||||
@@ -646,6 +632,58 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxTextSurfaceOverlay::OpenFontsLocked(std::int32_t font,
|
||||
float point_size) {
|
||||
if (!fonts_.empty() && fonts_font_ == font &&
|
||||
fonts_point_size_ == point_size) {
|
||||
return Status::Ok();
|
||||
}
|
||||
if (!TTF_Init()) {
|
||||
return RasterizationFailure("unable to initialize SDL3_ttf");
|
||||
}
|
||||
const std::vector<std::string> files =
|
||||
ResolveFontFiles(ResolveRobloxTextFont(font));
|
||||
std::vector<TTF_Font*> opened;
|
||||
opened.reserve(files.size());
|
||||
for (const std::string& file : files) {
|
||||
TTF_Font* candidate = TTF_OpenFont(file.c_str(), point_size);
|
||||
if (candidate != nullptr) {
|
||||
opened.push_back(candidate);
|
||||
}
|
||||
}
|
||||
// A failed reopen leaves the fonts already rendering in place.
|
||||
if (opened.empty()) {
|
||||
TTF_Quit();
|
||||
return RasterizationFailure("unable to open a Roblox or fallback font");
|
||||
}
|
||||
for (std::size_t index = 1; index < opened.size(); ++index) {
|
||||
(void)TTF_AddFallbackFont(opened.front(), opened[index]);
|
||||
}
|
||||
CloseFontsLocked();
|
||||
fonts_ = std::move(opened);
|
||||
ttf_initialized_ = true;
|
||||
fonts_font_ = font;
|
||||
fonts_point_size_ = point_size;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
void RobloxTextSurfaceOverlay::CloseFontsLocked() {
|
||||
if (!fonts_.empty()) {
|
||||
TTF_ClearFallbackFonts(fonts_.front());
|
||||
for (auto iterator = fonts_.rbegin(); iterator != fonts_.rend();
|
||||
++iterator) {
|
||||
TTF_CloseFont(*iterator);
|
||||
}
|
||||
fonts_.clear();
|
||||
}
|
||||
fonts_font_ = 0;
|
||||
fonts_point_size_ = 0.0F;
|
||||
if (ttf_initialized_) {
|
||||
TTF_Quit();
|
||||
ttf_initialized_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void RobloxTextSurfaceOverlay::ClearFrameLocked() {
|
||||
SecureClear(&rgba_);
|
||||
raster_revision_ = 0;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include "runtime/environment.h"
|
||||
#include "webview/roblox_challenge_features.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -401,6 +402,39 @@ Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command) {
|
||||
if (command.empty() || command.size() > kMaximumRobloxWebViewJsonBytes) {
|
||||
return false;
|
||||
}
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(command.begin(), command.end(), nullptr, false);
|
||||
const auto string_field = [](const nlohmann::json& object,
|
||||
const char* key) -> const std::string* {
|
||||
const auto field = object.find(key);
|
||||
return field != object.end() && field->is_string()
|
||||
? &field->get_ref<const std::string&>()
|
||||
: nullptr;
|
||||
};
|
||||
const auto object_field = [](const nlohmann::json& object,
|
||||
const char* key) -> const nlohmann::json* {
|
||||
const auto field = object.find(key);
|
||||
return field != object.end() && field->is_object() ? &*field : nullptr;
|
||||
};
|
||||
if (!document.is_object()) {
|
||||
return false;
|
||||
}
|
||||
const std::string* module = string_field(document, "moduleID");
|
||||
const std::string* function = string_field(document, "functionName");
|
||||
const nlohmann::json* outer = object_field(document, "params");
|
||||
const nlohmann::json* navigation =
|
||||
outer != nullptr ? object_field(*outer, "params") : nullptr;
|
||||
const std::string* feature =
|
||||
navigation != nullptr ? string_field(*navigation, "feature") : nullptr;
|
||||
return module != nullptr && *module == "Navigation" && function != nullptr &&
|
||||
*function == "navigateToFeature" && feature != nullptr &&
|
||||
webview::IsRobloxChallengeSolvedFeature(*feature);
|
||||
}
|
||||
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request) {
|
||||
if (request == nullptr) {
|
||||
@@ -911,6 +945,20 @@ Status RobloxWebViewBridge::Initialize() {
|
||||
// handler and window subscriptions are installed before the native Android
|
||||
// protocol is initialized. Calling the native initializer earlier loses
|
||||
// messages that it can publish synchronously during startup.
|
||||
// Roblox's Android app start holds a WebViewProtocol handle for the process
|
||||
// lifetime. Without one, the protocol is deleted as soon as each caller
|
||||
// releases it, and page callbacks wait for the next Lua WebView call to
|
||||
// recreate it. Taking it before the native initializer keeps the protocol
|
||||
// that initializer configures.
|
||||
if (symbols_.acquire_web_view_protocol != nullptr) {
|
||||
if (web_view_protocol_handle_.holder() == nullptr) {
|
||||
web_view_protocol_handle_ = symbols_.acquire_web_view_protocol();
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" [webview] WebViewProtocol getter not recognized; page "
|
||||
"callbacks may wait for the next Roblox WebView call\n");
|
||||
}
|
||||
jclass initialization_class = env->FindClass(kWebViewProtocolClass);
|
||||
if (initialization_class != nullptr) {
|
||||
symbols_.initialize_android_web_view_protocol(env, initialization_class);
|
||||
@@ -1266,6 +1314,7 @@ Status RobloxWebViewBridge::DispatchOpenRequest(
|
||||
exit_context->target->browser_login_fallback = browser_login_fallback;
|
||||
}
|
||||
}
|
||||
request.close_when_challenge_solved = source == OpenSource::kCaptcha;
|
||||
if (status.ok()) {
|
||||
status = sink_.dispatch_open(
|
||||
sink_.context, request,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
#include <unistd.h>
|
||||
#include <utility>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
@@ -438,8 +440,8 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
|
||||
const std::filesystem::path library = canonical_root / "libroblox.so";
|
||||
const std::filesystem::path package_root = canonical_root / "sober_apk";
|
||||
const std::filesystem::path base_apk = package_root / "base.apk";
|
||||
const std::filesystem::path x86_64_split_apk =
|
||||
package_root / "split_config.x86_64.apk";
|
||||
const std::filesystem::path arch_split_apk =
|
||||
package_root / compat::kGuestSplitApkFile;
|
||||
const std::filesystem::path assets_root = canonical_root / "assets";
|
||||
const std::filesystem::path assets = canonical_root / "assets/content";
|
||||
const std::filesystem::file_status library_status =
|
||||
@@ -460,7 +462,8 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
|
||||
for (const auto& [path, name] :
|
||||
std::array<std::pair<std::filesystem::path, const char*>, 2>{
|
||||
std::pair{base_apk, "base.apk"},
|
||||
std::pair{x86_64_split_apk, "split_config.x86_64.apk"}}) {
|
||||
std::pair{arch_split_apk,
|
||||
compat::kGuestSplitApkFile.data()}}) {
|
||||
const std::filesystem::file_status status =
|
||||
std::filesystem::symlink_status(path, filesystem_error);
|
||||
if (filesystem_error || !std::filesystem::is_regular_file(status) ||
|
||||
@@ -489,7 +492,7 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
|
||||
result.root = canonical_root;
|
||||
result.roblox_library = library;
|
||||
result.base_apk = base_apk;
|
||||
result.x86_64_split_apk = x86_64_split_apk;
|
||||
result.arch_split_apk = arch_split_apk;
|
||||
result.assets_content = assets;
|
||||
|
||||
const auto compatibility_manifest =
|
||||
|
||||
+25
-13
@@ -28,6 +28,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/android_manifest.h"
|
||||
#include "update/apk_signature.h"
|
||||
#include "update/payload_integrity.h"
|
||||
@@ -173,10 +174,15 @@ bool EndsWith(std::string_view value, std::string_view suffix) {
|
||||
struct ApkCandidate {
|
||||
std::filesystem::path path;
|
||||
AndroidManifestIdentity identity;
|
||||
bool has_x86_64_library = false;
|
||||
bool has_guest_library = false;
|
||||
bool has_assets = false;
|
||||
};
|
||||
|
||||
std::string GuestLibEntryName() {
|
||||
return std::string(compat::kGuestLibApkEntryPrefix) +
|
||||
std::string(compat::kGuestAbi) + "/libroblox.so";
|
||||
}
|
||||
|
||||
bool CollectCandidates(const std::vector<std::filesystem::path>& archives,
|
||||
const std::filesystem::path& directory,
|
||||
std::vector<std::filesystem::path>* candidates,
|
||||
@@ -250,7 +256,7 @@ bool InspectCandidates(const std::vector<std::filesystem::path>& paths,
|
||||
ApkCandidate candidate;
|
||||
candidate.path = path;
|
||||
candidate.identity = std::move(identity);
|
||||
candidate.has_x86_64_library = HasEntry(list, "lib/x86_64/libroblox.so");
|
||||
candidate.has_guest_library = HasEntry(list, GuestLibEntryName());
|
||||
candidate.has_assets = std::any_of(
|
||||
list.entries.begin(), list.entries.end(), [](const ZipEntry& entry) {
|
||||
return entry.name.size() > 7 && entry.name.substr(0, 7) == "assets/";
|
||||
@@ -314,7 +320,8 @@ std::vector<std::string> CommonTrustedCertificates(
|
||||
const ApkSignatureResult split_signature =
|
||||
base == split ? base_signature : VerifyApkSignature(split);
|
||||
if (!split_signature) {
|
||||
*error = "x86_64 APK signature: " + split_signature.error;
|
||||
*error = std::string(compat::kGuestAbi) +
|
||||
" APK signature: " + split_signature.error;
|
||||
return {};
|
||||
}
|
||||
std::vector<std::string> accepted;
|
||||
@@ -356,9 +363,11 @@ bool ValidateElf(const std::filesystem::path& path, std::string* build_id,
|
||||
}
|
||||
GElf_Ehdr header{};
|
||||
if (gelf_getehdr(elf.get(), &header) == nullptr ||
|
||||
header.e_machine != EM_X86_64 || gelf_getclass(elf.get()) != ELFCLASS64) {
|
||||
header.e_machine != compat::kGuestElfMachine ||
|
||||
gelf_getclass(elf.get()) != ELFCLASS64) {
|
||||
close_elf();
|
||||
*error = "libroblox.so is not an x86-64 ELF";
|
||||
*error = "libroblox.so is not a " + std::string(compat::kGuestAbi) +
|
||||
" ELF";
|
||||
return false;
|
||||
}
|
||||
std::size_t section_count = 0;
|
||||
@@ -464,16 +473,18 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
base = &candidate;
|
||||
}
|
||||
}
|
||||
const std::string guest_split_name(compat::kGuestApkSplitName);
|
||||
for (const ApkCandidate& candidate : candidates) {
|
||||
if (candidate.has_x86_64_library &&
|
||||
(candidate.identity.split_name == "config.x86_64" ||
|
||||
if (candidate.has_guest_library &&
|
||||
(candidate.identity.split_name == guest_split_name ||
|
||||
candidate.identity.split_name.empty()) &&
|
||||
split == nullptr) {
|
||||
split = &candidate;
|
||||
}
|
||||
}
|
||||
if (base == nullptr || split == nullptr) {
|
||||
result.error = "no matching Roblox base and x86_64 APK pair was found";
|
||||
result.error = "no matching Roblox base and " +
|
||||
std::string(compat::kGuestAbi) + " APK pair was found";
|
||||
return result;
|
||||
}
|
||||
const std::set<std::string> trusted =
|
||||
@@ -488,12 +499,13 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
if (filesystem_error ||
|
||||
!CopyRegular(base->path, prepared / "sober_apk/base.apk",
|
||||
&result.error) ||
|
||||
!CopyRegular(split->path, prepared / "sober_apk/split_config.x86_64.apk",
|
||||
!CopyRegular(split->path,
|
||||
prepared / "sober_apk" / compat::kGuestSplitApkFile,
|
||||
&result.error)) {
|
||||
if (result.error.empty()) result.error = "cannot create prepared payload";
|
||||
return result;
|
||||
}
|
||||
if (!ExtractZipEntry(split->path, "lib/x86_64/libroblox.so",
|
||||
if (!ExtractZipEntry(split->path, GuestLibEntryName(),
|
||||
prepared / "libroblox.so", kMaximumApkBytes,
|
||||
&result.error)) {
|
||||
return result;
|
||||
@@ -527,7 +539,7 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
const std::string base_hash =
|
||||
HashRegularFile(prepared / "sober_apk/base.apk", &hash_error);
|
||||
const std::string split_hash = HashRegularFile(
|
||||
prepared / "sober_apk/split_config.x86_64.apk", &hash_error);
|
||||
prepared / "sober_apk" / compat::kGuestSplitApkFile, &hash_error);
|
||||
if (!hash_error.empty()) {
|
||||
result.error = hash_error;
|
||||
return result;
|
||||
@@ -537,12 +549,12 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
{"package", "com.roblox.client"},
|
||||
{"version_name", expected.version_name},
|
||||
{"version_code", expected.version_code},
|
||||
{"abi", "x86_64"},
|
||||
{"abi", std::string(compat::kGuestAbi)},
|
||||
{"elf_build_id", build_id},
|
||||
{"sha256",
|
||||
{{"libroblox", library_hash},
|
||||
{"base_apk", base_hash},
|
||||
{"x86_64_split_apk", split_hash}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey), split_hash}}},
|
||||
{"signing_certificates_sha256", accepted},
|
||||
{"assets",
|
||||
{{"file_count", asset_count_verified},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/http_download.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -199,7 +200,8 @@ std::vector<std::string> ParseApkPureExactDownloadUrls(
|
||||
}
|
||||
|
||||
ProviderVersion ApkPureProvider::CheckLatest() const {
|
||||
const HttpBytesResult metadata = FetchMetadata("x86_64");
|
||||
const HttpBytesResult metadata =
|
||||
FetchMetadata(std::string(compat::kGuestAbi));
|
||||
if (!metadata) return ProviderVersion{{}, 0, metadata.error};
|
||||
return ParseApkPureLatestMetadata(metadata.bytes);
|
||||
}
|
||||
@@ -223,7 +225,7 @@ ProviderDownloadResult ApkPureProvider::DownloadExact(
|
||||
result.error = "APKPure output directory must exist and be empty";
|
||||
return result;
|
||||
}
|
||||
HttpBytesResult metadata = FetchMetadata("x86_64");
|
||||
HttpBytesResult metadata = FetchMetadata(std::string(compat::kGuestAbi));
|
||||
if (!metadata) {
|
||||
result.error = metadata.error;
|
||||
return result;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/payload_integrity.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -260,7 +261,7 @@ std::string PayloadRuntimeFingerprint(
|
||||
evidence.append("base_apk=")
|
||||
.append(payload.metadata.base_apk_sha256)
|
||||
.append("\n");
|
||||
evidence.append("x86_64_split=")
|
||||
evidence.append(std::string(compat::kGuestAbi) + "_split=")
|
||||
.append(payload.metadata.split_apk_sha256)
|
||||
.append("\n");
|
||||
evidence.append("assets=")
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
#include <cerrno>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
namespace {
|
||||
@@ -81,6 +84,16 @@ CompatibilityCatalogResult LoadCompatibilityCatalog(
|
||||
profile.value("allow_legacy_binary_patches", true)) {
|
||||
continue;
|
||||
}
|
||||
const auto declared_abi = profile.find("abi");
|
||||
if (declared_abi != profile.end() && !declared_abi->is_string()) {
|
||||
result.error = "supported compatibility profile is incomplete";
|
||||
return result;
|
||||
}
|
||||
const std::string_view profile_abi =
|
||||
declared_abi == profile.end()
|
||||
? std::string_view("x86_64")
|
||||
: std::string_view(declared_abi->get_ref<const std::string&>());
|
||||
if (profile_abi != compat::kGuestAbi) continue;
|
||||
if (!profile.contains("version_name") ||
|
||||
!profile["version_name"].is_string() ||
|
||||
!profile.contains("version_code") ||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/fmod_output_device_contract.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/payload_integrity.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -222,9 +223,10 @@ class ElfImage final {
|
||||
if (elf_ == nullptr || elf_kind(elf_) != ELF_K_ELF ||
|
||||
gelf_getehdr(elf_, &header) == nullptr ||
|
||||
gelf_getclass(elf_) != ELFCLASS64 || header.e_type != ET_DYN ||
|
||||
header.e_machine != EM_X86_64 || header.e_version != EV_CURRENT ||
|
||||
header.e_machine != compat::kGuestElfMachine ||
|
||||
header.e_version != EV_CURRENT ||
|
||||
header.e_ident[EI_DATA] != ELFDATA2LSB) {
|
||||
*error = "candidate must be a little-endian x86-64 ET_DYN ELF";
|
||||
*error = "candidate must be a little-endian guest-abi ET_DYN ELF";
|
||||
return false;
|
||||
}
|
||||
std::size_t program_count = 0;
|
||||
@@ -1901,7 +1903,7 @@ std::optional<Json> LoadCandidateMetadata(const std::filesystem::path& root,
|
||||
metadata->contains("sha256") ? &(*metadata)["sha256"] : nullptr;
|
||||
if (metadata->value("schema_version", 0) != 1 ||
|
||||
metadata->value("package", "") != "com.roblox.client" ||
|
||||
metadata->value("abi", "") != "x86_64" || version_name.empty() ||
|
||||
metadata->value("abi", "") != compat::kGuestAbi || version_name.empty() ||
|
||||
version_code == 0 ||
|
||||
metadata->value("elf_build_id", "") != candidate.build_id() ||
|
||||
hashes == nullptr || !hashes->is_object() ||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
namespace {
|
||||
|
||||
@@ -291,7 +293,7 @@ PayloadIntegrityResult InspectPreparedPayload(
|
||||
for (const std::filesystem::path& relative : {
|
||||
std::filesystem::path("libroblox.so"),
|
||||
std::filesystem::path("sober_apk/base.apk"),
|
||||
std::filesystem::path("sober_apk/split_config.x86_64.apk"),
|
||||
std::filesystem::path("sober_apk") / compat::kGuestSplitApkFile,
|
||||
std::filesystem::path("roblox_payload.json"),
|
||||
}) {
|
||||
const auto status =
|
||||
@@ -335,7 +337,7 @@ PayloadIntegrityResult InspectPreparedPayload(
|
||||
result.metadata.library_sha256 = document["sha256"].value("libroblox", "");
|
||||
result.metadata.base_apk_sha256 = document["sha256"].value("base_apk", "");
|
||||
result.metadata.split_apk_sha256 =
|
||||
document["sha256"].value("x86_64_split_apk", "");
|
||||
document["sha256"].value(std::string(compat::kGuestSplitApkHashKey), "");
|
||||
if (!document["assets"].contains("file_count") ||
|
||||
!document["assets"]["file_count"].is_number_unsigned()) {
|
||||
result.error = "payload metadata asset count is invalid";
|
||||
@@ -368,7 +370,8 @@ PayloadIntegrityResult VerifyPreparedPayload(
|
||||
const std::array<std::pair<std::filesystem::path, std::string>, 3> files = {{
|
||||
{"libroblox.so", result.metadata.library_sha256},
|
||||
{"sober_apk/base.apk", result.metadata.base_apk_sha256},
|
||||
{"sober_apk/split_config.x86_64.apk", result.metadata.split_apk_sha256},
|
||||
{std::filesystem::path("sober_apk") / compat::kGuestSplitApkFile,
|
||||
result.metadata.split_apk_sha256},
|
||||
}};
|
||||
for (const auto& [relative, expected] : files) {
|
||||
const std::string actual =
|
||||
|
||||
@@ -108,15 +108,9 @@ struct SurfaceState {
|
||||
unsigned int resource_retries = 0;
|
||||
};
|
||||
|
||||
// A solved challenge stays open: the host forwards the event to Roblox, and
|
||||
// Roblox or the host closes the window after Roblox has received it.
|
||||
struct CallbackConfirmation {
|
||||
CallbackConfirmation(GtkWindow* surface_window, CaptchaEventType event_type)
|
||||
: type(event_type) {
|
||||
g_weak_ref_init(&window, G_OBJECT(surface_window));
|
||||
}
|
||||
|
||||
~CallbackConfirmation() { g_weak_ref_clear(&window); }
|
||||
|
||||
GWeakRef window;
|
||||
CaptchaEventType type = CaptchaEventType::kShown;
|
||||
};
|
||||
|
||||
@@ -612,16 +606,6 @@ void FinishCallbackConfirmation(GObject* source_object, GAsyncResult* result,
|
||||
if (value != nullptr) {
|
||||
g_object_unref(value);
|
||||
}
|
||||
|
||||
auto* window_object =
|
||||
static_cast<GObject*>(g_weak_ref_get(&confirmation->window));
|
||||
if (confirmed && confirmation->type == CaptchaEventType::kSuccess &&
|
||||
window_object != nullptr) {
|
||||
gtk_window_close(GTK_WINDOW(window_object));
|
||||
}
|
||||
if (window_object != nullptr) {
|
||||
g_object_unref(window_object);
|
||||
}
|
||||
delete confirmation;
|
||||
}
|
||||
|
||||
@@ -644,9 +628,8 @@ void HandleHybridCommand(WebKitUserContentManager* manager,
|
||||
if (window_object == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto* confirmation =
|
||||
new CallbackConfirmation(GTK_WINDOW(window_object), event.type);
|
||||
g_object_unref(window_object);
|
||||
auto* confirmation = new CallbackConfirmation{event.type};
|
||||
const std::string script = BuildCallbackScript(event.callback_id);
|
||||
WebKitWebView* web_view = WEBKIT_WEB_VIEW(
|
||||
g_object_get_data(G_OBJECT(manager), "mocktail-web-view"));
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "webview/roblox_challenge_features.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace webview {
|
||||
namespace {
|
||||
@@ -306,7 +308,7 @@ bool ParseCaptchaEvent(std::string_view command, CaptchaEvent* event) {
|
||||
event->type = CaptchaEventType::kShown;
|
||||
return true;
|
||||
}
|
||||
if (feature == "CaptchaSuccess") {
|
||||
if (IsRobloxChallengeSolvedFeature(feature)) {
|
||||
event->type = CaptchaEventType::kSuccess;
|
||||
return true;
|
||||
}
|
||||
|
||||
+43
-9
@@ -151,13 +151,33 @@ static std::unique_ptr<WindowResizeReadinessGate> g_resize_readiness_gate;
|
||||
static std::unique_ptr<SdlTextInputBackend> g_text_input_backend;
|
||||
static std::unique_ptr<WindowTextInputOwner> g_text_input_owner;
|
||||
static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
|
||||
static std::unique_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
|
||||
// The owner is created and destroyed on the SDL thread, but the experience
|
||||
// composition reaches it from its own thread. The mutex guards the pointer
|
||||
// only; a caller off the SDL thread holds a strong reference for its call.
|
||||
static std::mutex g_pointer_capture_owner_mutex;
|
||||
static std::shared_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
|
||||
static std::unique_ptr<graphics::GlesTextOverlayCompositor> g_gles_text_overlay;
|
||||
static char g_preferred_egl_library[4096];
|
||||
static char g_preferred_gles_library[4096];
|
||||
static bool g_auto_angle_retry_attempted = false;
|
||||
static std::filesystem::path g_window_state_path;
|
||||
|
||||
// Keeps the owner alive past a concurrent Shutdown on the SDL thread.
|
||||
static std::shared_ptr<WindowPointerCaptureOwner> SharedPointerCaptureOwner() {
|
||||
std::lock_guard<std::mutex> lock(g_pointer_capture_owner_mutex);
|
||||
return g_pointer_capture_owner;
|
||||
}
|
||||
|
||||
// Drops the reference under the lock and destroys the owner outside it, so a
|
||||
// concurrent caller finishing its call cannot block the lock.
|
||||
static void ResetPointerCaptureOwner() {
|
||||
std::shared_ptr<WindowPointerCaptureOwner> owner;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_pointer_capture_owner_mutex);
|
||||
owner.swap(g_pointer_capture_owner);
|
||||
}
|
||||
}
|
||||
|
||||
WindowStartupPresentationPlan RestoredWindowPresentationPlan() {
|
||||
if (!g_state.state_persistence_active) {
|
||||
return {};
|
||||
@@ -865,8 +885,11 @@ bool ActivateWindowEventLifecycles() {
|
||||
std::make_unique<WindowTextInputOwner>(g_text_input_backend.get());
|
||||
g_pointer_capture_backend =
|
||||
std::make_unique<SdlPointerCaptureBackend>(g_state.sdl_window);
|
||||
g_pointer_capture_owner = std::make_unique<WindowPointerCaptureOwner>(
|
||||
g_pointer_capture_backend.get());
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_pointer_capture_owner_mutex);
|
||||
g_pointer_capture_owner = std::make_shared<WindowPointerCaptureOwner>(
|
||||
g_pointer_capture_backend.get());
|
||||
}
|
||||
fprintf(stderr,
|
||||
" [input] SDL event pump target=%llu Hz raw-motion, no smoothing\n",
|
||||
static_cast<unsigned long long>(kProductionInputPumpHz));
|
||||
@@ -1484,13 +1507,24 @@ bool SetPlatformEventObserver(PlatformEventObserver observer, void* context) {
|
||||
void ClearPlatformEventObserver() { g_platform_event_observer.Clear(); }
|
||||
|
||||
bool SetMouseLockQueryCallback(MouseLockQueryCallback callback, void* context) {
|
||||
return g_pointer_capture_owner != nullptr &&
|
||||
g_pointer_capture_owner->RegisterQuery(callback, context);
|
||||
const std::shared_ptr<WindowPointerCaptureOwner> owner =
|
||||
SharedPointerCaptureOwner();
|
||||
return owner != nullptr && owner->RegisterQuery(callback, context);
|
||||
}
|
||||
|
||||
void SetGameSessionActive(bool active) {
|
||||
const std::shared_ptr<WindowPointerCaptureOwner> owner =
|
||||
SharedPointerCaptureOwner();
|
||||
if (owner != nullptr) {
|
||||
owner->SetGameSessionActive(active);
|
||||
}
|
||||
}
|
||||
|
||||
void ClearMouseLockQueryCallback() {
|
||||
if (g_pointer_capture_owner != nullptr) {
|
||||
g_pointer_capture_owner->ClearQuery();
|
||||
const std::shared_ptr<WindowPointerCaptureOwner> owner =
|
||||
SharedPointerCaptureOwner();
|
||||
if (owner != nullptr) {
|
||||
owner->ClearQuery();
|
||||
}
|
||||
}
|
||||
bool SetPreTextInputPumpCallback(PreTextInputPumpCallback callback,
|
||||
@@ -2336,7 +2370,7 @@ void Shutdown() {
|
||||
if (!g_state.initialised) {
|
||||
g_fullscreen_request_gate.Reset();
|
||||
g_fullscreen_menu_request_gate.Reset();
|
||||
g_pointer_capture_owner.reset();
|
||||
ResetPointerCaptureOwner();
|
||||
g_pointer_capture_backend.reset();
|
||||
g_text_input_owner.reset();
|
||||
g_text_input_backend.reset();
|
||||
@@ -2355,7 +2389,7 @@ void Shutdown() {
|
||||
!g_pointer_capture_owner->Shutdown()) {
|
||||
fprintf(stderr, " [input] SDL pointer capture shutdown failed\n");
|
||||
}
|
||||
g_pointer_capture_owner.reset();
|
||||
ResetPointerCaptureOwner();
|
||||
g_pointer_capture_backend.reset();
|
||||
g_text_input_owner.reset();
|
||||
g_text_input_backend.reset();
|
||||
|
||||
@@ -83,6 +83,10 @@ void ClearPlatformEventObserver();
|
||||
bool SetMouseLockQueryCallback(MouseLockQueryCallback callback, void* context);
|
||||
void ClearMouseLockQueryCallback();
|
||||
|
||||
// Gates the RMB camera-drag fallback, which only stands in for a camera and
|
||||
// must not capture over the app surface's own UI.
|
||||
void SetGameSessionActive(bool active);
|
||||
|
||||
// Clear waits for a pre-input callback already in flight.
|
||||
bool SetPreTextInputPumpCallback(PreTextInputPumpCallback callback,
|
||||
void* context);
|
||||
|
||||
@@ -95,7 +95,7 @@ bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
|
||||
}
|
||||
const bool client_active = query_ok;
|
||||
const bool native_capture_active = client_active && native_lock_active;
|
||||
const bool right_drag_active = right_button_held_;
|
||||
const bool right_drag_active = right_button_held_ && game_session_active_;
|
||||
// Roblox can keep Shift Lock active while chat has text focus. Honor that
|
||||
// native lock so camera motion stays relative and cannot leave the window.
|
||||
// Text editing only suppresses the host's fallback RMB camera capture.
|
||||
@@ -116,6 +116,10 @@ bool WindowPointerCaptureOwner::OnRightButton(bool pressed,
|
||||
return Pump(text_input_active);
|
||||
}
|
||||
|
||||
void WindowPointerCaptureOwner::SetGameSessionActive(bool active) {
|
||||
game_session_active_ = active;
|
||||
}
|
||||
|
||||
bool WindowPointerCaptureOwner::OnShiftKeyPressed(bool text_input_active) {
|
||||
focused_ = true;
|
||||
return Pump(text_input_active);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_WINDOW_WINDOW_POINTER_CAPTURE_OWNER_H_
|
||||
#define MOCKTAIL_WINDOW_WINDOW_POINTER_CAPTURE_OWNER_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
@@ -31,6 +32,10 @@ class WindowPointerCaptureOwner final {
|
||||
// Android's lock-center query does not represent desktop RMB camera drag,
|
||||
// so the SDL owner tracks that transient capture source independently.
|
||||
bool OnRightButton(bool pressed, bool text_input_active);
|
||||
// The RMB fallback only stands in for a camera. Outside an experience there
|
||||
// is none, and capturing there freezes the cursor over Roblox's own UI.
|
||||
// Starts false: the app surface is what the client shows first.
|
||||
void SetGameSessionActive(bool active);
|
||||
// Shift can enable Roblox's persistent mouse lock while a transient RMB
|
||||
// camera drag is active. Refresh the native state after dispatching that
|
||||
// key so the subsequent RMB release preserves the intentional lock.
|
||||
@@ -62,6 +67,8 @@ class WindowPointerCaptureOwner final {
|
||||
bool clearing_ = false;
|
||||
bool focused_ = true;
|
||||
bool right_button_held_ = false;
|
||||
// Written by the experience composition, read on the SDL thread.
|
||||
std::atomic<bool> game_session_active_{false};
|
||||
bool native_lock_observed_ = false;
|
||||
bool native_lock_was_active_before_right_drag_ = false;
|
||||
bool shift_key_pressed_during_right_drag_ = false;
|
||||
|
||||
@@ -370,6 +370,15 @@ target_link_libraries(roblox_permissions_bridge_test PRIVATE
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_permissions_bridge_test)
|
||||
|
||||
add_executable(roblox_account_bridge_test roblox_account_bridge_test.cc)
|
||||
target_link_libraries(roblox_account_bridge_test PRIVATE
|
||||
Mocktail::LegacyJni
|
||||
Mocktail::Runtime
|
||||
GTest::gtest_main
|
||||
Threads::Threads
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_account_bridge_test)
|
||||
|
||||
add_executable(roblox_browser_service_bridge_test
|
||||
roblox_browser_service_bridge_test.cc
|
||||
)
|
||||
@@ -713,6 +722,15 @@ target_link_libraries(elf_build_id_test PRIVATE
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
add_executable(web_view_protocol_contract_test
|
||||
web_view_protocol_contract_test.cc
|
||||
)
|
||||
target_link_libraries(web_view_protocol_contract_test PRIVATE
|
||||
Mocktail::Compat
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(web_view_protocol_contract_test)
|
||||
|
||||
add_executable(build_profile_test build_profile_test.cc)
|
||||
target_link_libraries(build_profile_test PRIVATE
|
||||
Mocktail::Compat
|
||||
@@ -1006,6 +1024,7 @@ gtest_discover_tests(roblox_game_session_native_adapter_test)
|
||||
gtest_discover_tests(roblox_experience_launch_bridge_test)
|
||||
gtest_discover_tests(roblox_web_view_bridge_test)
|
||||
gtest_discover_tests(roblox_permissions_bridge_test)
|
||||
gtest_discover_tests(roblox_account_bridge_test)
|
||||
gtest_discover_tests(roblox_browser_service_bridge_test)
|
||||
gtest_discover_tests(roblox_fresh_game_launch_controller_test)
|
||||
gtest_discover_tests(roblox_experience_composition_test)
|
||||
@@ -1039,6 +1058,7 @@ gtest_discover_tests(auth_service_test)
|
||||
gtest_discover_tests(auth_runtime_composition_test)
|
||||
gtest_discover_tests(client_settings_service_test)
|
||||
gtest_discover_tests(elf_build_id_test)
|
||||
gtest_discover_tests(web_view_protocol_contract_test)
|
||||
gtest_discover_tests(build_profile_test)
|
||||
gtest_discover_tests(host_abi_profile_test)
|
||||
gtest_discover_tests(payload_compatibility_host_abi_gate_test)
|
||||
|
||||
@@ -45,6 +45,8 @@ jq --arg hash "${UPDATED_FFMPEG_HASH}" '
|
||||
mkdir -p "${TEMP_DIR}/project/scripts" "${TEMP_DIR}/project/packaging"
|
||||
cp "${ROOT}/scripts/install_anylinux_dependencies.sh" "${TEMP_DIR}/project/scripts/"
|
||||
cp "${TEMP_DIR}/manifest.txt" "${TEMP_DIR}/project/packaging/anylinux-dependencies.txt"
|
||||
cp "${ROOT}/packaging/anylinux-dependencies-aarch64.txt" \
|
||||
"${TEMP_DIR}/project/packaging/"
|
||||
|
||||
# Record requests without any network access or changes to host packages.
|
||||
curl() {
|
||||
@@ -121,13 +123,13 @@ ExpectRejectedSnapshot() {
|
||||
local description="$1" filter="$2"
|
||||
jq "${filter}" "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
|
||||
"${TEMP_DIR}/invalid.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
"${TEMP_DIR}/invalid.json" x86_64) >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
Fail "accepted ${description}"
|
||||
fi
|
||||
}
|
||||
|
||||
(AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
|
||||
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/snapshot.tsv" ||
|
||||
"${TEMP_DIR}/release.json" x86_64) >"${TEMP_DIR}/snapshot.tsv" ||
|
||||
Fail 'rejected valid dependency snapshot'
|
||||
printf '%s\t%s\t%s\n' \
|
||||
"${FFMPEG_HASH}" "${FFMPEG}" "${API}/assets/10" \
|
||||
@@ -136,6 +138,40 @@ printf '%s\t%s\t%s\n' \
|
||||
cmp "${TEMP_DIR}/expected-snapshot.tsv" "${TEMP_DIR}/snapshot.tsv" ||
|
||||
Fail 'incorrect dependency snapshot'
|
||||
|
||||
readonly ARM_PACKAGE=gdk-pixbuf2-mini-aarch64.pkg.tar.xz
|
||||
printf '%s\n' "${ARM_PACKAGE}" >"${TEMP_DIR}/arm-manifest.txt"
|
||||
jq --null-input --arg api "${API}" --arg name "${ARM_PACKAGE}" \
|
||||
--arg hash "${FFMPEG_HASH}" '
|
||||
{tag_name: "continuous", assets: [
|
||||
{id: 40, name: $name, state: "uploaded",
|
||||
digest: ("sha256:" + $hash), url: ($api + "/assets/40")}
|
||||
]}
|
||||
' >"${TEMP_DIR}/arm-release.json"
|
||||
AnyLinuxDependencySnapshot "${TEMP_DIR}/arm-manifest.txt" \
|
||||
"${TEMP_DIR}/arm-release.json" aarch64 >"${TEMP_DIR}/arm-snapshot.tsv" ||
|
||||
Fail 'rejected valid aarch64 dependency snapshot'
|
||||
printf '%s\t%s\t%s\n' "${FFMPEG_HASH}" "${ARM_PACKAGE}" \
|
||||
"${API}/assets/40" >"${TEMP_DIR}/expected-arm-snapshot.tsv"
|
||||
cmp "${TEMP_DIR}/expected-arm-snapshot.tsv" "${TEMP_DIR}/arm-snapshot.tsv" ||
|
||||
Fail 'incorrect aarch64 dependency snapshot'
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/arm-manifest.txt" \
|
||||
"${TEMP_DIR}/arm-release.json" x86_64) >/dev/null 2>&1; then
|
||||
Fail 'accepted an aarch64 dependency on an x86_64 host'
|
||||
fi
|
||||
bash -Eeuo pipefail -c '
|
||||
source "$1/project/scripts/install_anylinux_dependencies.sh"
|
||||
AnyLinuxDependencyRequireContainer() { :; }
|
||||
uname() { printf "aarch64\n"; }
|
||||
AnyLinuxDownloadDependencies() {
|
||||
printf "%s\n" "$1" >"${TEMP_DIR}/arm-selected-manifest"
|
||||
exit 0
|
||||
}
|
||||
AnyLinuxDependenciesMain
|
||||
' _ "${TEMP_DIR}" || Fail 'aarch64 manifest selection failed'
|
||||
grep -Fxq "${TEMP_DIR}/project/packaging/anylinux-dependencies-aarch64.txt" \
|
||||
"${TEMP_DIR}/arm-selected-manifest" ||
|
||||
Fail 'aarch64 build did not select its dependency manifest'
|
||||
|
||||
ExpectRejectedSnapshot 'missing asset' '.assets |= .[1:]'
|
||||
ExpectRejectedSnapshot 'duplicate asset' '.assets += [.assets[0]]'
|
||||
ExpectRejectedSnapshot 'absent SHA256' '.assets[0].digest = null'
|
||||
@@ -149,7 +185,7 @@ for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
|
||||
"${FFMPEG} extra" "${FFMPEG}"$'\n'"${FFMPEG}" ''; do
|
||||
printf '%s\n' "${invalid_manifest}" >"${TEMP_DIR}/invalid-manifest.txt"
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/invalid-manifest.txt" \
|
||||
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
"${TEMP_DIR}/release.json" x86_64) >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
Fail 'accepted unsafe, duplicate, or empty package allowlist'
|
||||
fi
|
||||
done
|
||||
@@ -166,6 +202,7 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
|
||||
if GH_TOKEN=fixture-token bash -Eeuo pipefail -c '
|
||||
source "$1/project/scripts/install_anylinux_dependencies.sh"
|
||||
AnyLinuxDependencyRequireContainer() { :; }
|
||||
uname() { printf "x86_64\n"; }
|
||||
AnyLinuxDependenciesMain
|
||||
' _ "${TEMP_DIR}" \
|
||||
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "compat/bionic_abi_exports.h"
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <fcntl.h>
|
||||
@@ -37,8 +39,10 @@ TEST(BionicAbiExportsTest, ReportsStableAndroidSystemProperties) {
|
||||
EXPECT_STREQ("35", value.data());
|
||||
|
||||
value.fill('\0');
|
||||
EXPECT_EQ(6, __system_property_get("ro.product.cpu.abi", value.data()));
|
||||
EXPECT_STREQ("x86_64", value.data());
|
||||
EXPECT_EQ(static_cast<int>(mocktail::compat::kGuestAbi.size()),
|
||||
__system_property_get("ro.product.cpu.abi", value.data()));
|
||||
EXPECT_STREQ(std::string(mocktail::compat::kGuestAbi).c_str(),
|
||||
value.data());
|
||||
|
||||
value.fill('x');
|
||||
EXPECT_EQ(0, __system_property_get("mocktail.unknown", value.data()));
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <sched.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "compat/bionic_abi_exports.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
|
||||
@@ -189,9 +191,10 @@ TEST(BionicPthreadCreateRuntimeTest, GrowsRejectedExplicitGuestStack) {
|
||||
0);
|
||||
ASSERT_EQ(pthread_attr_destroy(&host_defaults), 0);
|
||||
|
||||
pthread_attr_t guest_attributes;
|
||||
ASSERT_EQ(pthread_attr_init(&guest_attributes), 0);
|
||||
ASSERT_EQ(pthread_attr_setstacksize(&guest_attributes, kRequestedStackSize),
|
||||
MocktailBionicPthreadAttr guest_attributes;
|
||||
ASSERT_EQ(mocktail_pthread_attr_init(&guest_attributes), 0);
|
||||
ASSERT_EQ(mocktail_pthread_attr_setstacksize(&guest_attributes,
|
||||
kRequestedStackSize),
|
||||
0);
|
||||
|
||||
g_intercepted_create_calls.store(0, std::memory_order_release);
|
||||
@@ -206,7 +209,7 @@ TEST(BionicPthreadCreateRuntimeTest, GrowsRejectedExplicitGuestStack) {
|
||||
&thread, &guest_attributes, RecordThreadStackSize, nullptr);
|
||||
g_intercept_pthread_create.store(false, std::memory_order_release);
|
||||
|
||||
ASSERT_EQ(pthread_attr_destroy(&guest_attributes), 0);
|
||||
ASSERT_EQ(mocktail_pthread_attr_destroy(&guest_attributes), 0);
|
||||
ASSERT_EQ(result, 0);
|
||||
ASSERT_EQ(pthread_join(thread, nullptr), 0);
|
||||
EXPECT_EQ(g_intercepted_create_calls.load(std::memory_order_acquire), 3);
|
||||
@@ -223,15 +226,16 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
|
||||
constexpr size_t kRequestedStackSize = 2 * 1024 * 1024;
|
||||
g_observed_stack_size.store(0, std::memory_order_release);
|
||||
|
||||
pthread_attr_t attributes;
|
||||
ASSERT_EQ(pthread_attr_init(&attributes), 0);
|
||||
ASSERT_EQ(pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
|
||||
MocktailBionicPthreadAttr attributes;
|
||||
ASSERT_EQ(mocktail_pthread_attr_init(&attributes), 0);
|
||||
ASSERT_EQ(
|
||||
mocktail_pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
|
||||
|
||||
pthread_t thread{};
|
||||
ASSERT_EQ(mocktail_bionic_pthread_create(&thread, &attributes,
|
||||
RecordThreadStackSize, nullptr),
|
||||
0);
|
||||
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
|
||||
EXPECT_EQ(mocktail_pthread_attr_destroy(&attributes), 0);
|
||||
ASSERT_EQ(pthread_join(thread, nullptr), 0);
|
||||
ExpectRequestedStackSize(
|
||||
g_observed_stack_size.load(std::memory_order_acquire),
|
||||
@@ -240,13 +244,13 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
|
||||
|
||||
TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
|
||||
for (const int policy : {SCHED_FIFO, SCHED_RR}) {
|
||||
pthread_attr_t attributes;
|
||||
ASSERT_EQ(pthread_attr_init(&attributes), 0);
|
||||
ASSERT_EQ(pthread_attr_setinheritsched(&attributes, PTHREAD_EXPLICIT_SCHED), 0);
|
||||
ASSERT_EQ(pthread_attr_setschedpolicy(&attributes, policy), 0);
|
||||
MocktailBionicPthreadAttr attributes;
|
||||
ASSERT_EQ(mocktail_pthread_attr_init(&attributes), 0);
|
||||
attributes.sched_policy = policy;
|
||||
sched_param parameters{};
|
||||
parameters.sched_priority = 99;
|
||||
ASSERT_EQ(pthread_attr_setschedparam(&attributes, ¶meters), 0);
|
||||
ASSERT_EQ(
|
||||
mocktail_pthread_attr_setschedparam(&attributes, ¶meters), 0);
|
||||
g_intercepted_create_calls.store(0);
|
||||
g_intercept_pthread_create.store(true);
|
||||
pthread_t thread{};
|
||||
@@ -255,7 +259,7 @@ TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
|
||||
g_intercept_pthread_create.store(false);
|
||||
EXPECT_EQ(result, EPERM);
|
||||
EXPECT_EQ(g_intercepted_create_calls.load(), 0);
|
||||
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
|
||||
EXPECT_EQ(mocktail_pthread_attr_destroy(&attributes), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,69 @@ TEST(ChromeTraceWriterTest, ActiveProfileTraceIsOffWithoutEnvironment) {
|
||||
EXPECT_EQ(ActiveProfileTrace(), nullptr);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, CpuClocksAdvanceWithWork) {
|
||||
// The process window encloses the thread window.
|
||||
const std::uint64_t process_before = TraceProcessCpuNanos();
|
||||
const std::uint64_t thread_before = TraceThreadCpuNanos();
|
||||
volatile std::uint64_t sink = 0;
|
||||
const auto until =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(20);
|
||||
while (std::chrono::steady_clock::now() < until) {
|
||||
sink = sink + 1;
|
||||
}
|
||||
const std::uint64_t thread_used = TraceThreadCpuNanos() - thread_before;
|
||||
const std::uint64_t process_used = TraceProcessCpuNanos() - process_before;
|
||||
EXPECT_GE(thread_used, 10'000'000U);
|
||||
EXPECT_GE(process_used, thread_used);
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, FirstPresentHasNoFrame) {
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
EXPECT_FALSE(sampler.Sample(1, 10'000'000, 50'000'000, &sample));
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, MeasuresCpuSpentSinceThePreviousPresent) {
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
|
||||
ASSERT_TRUE(sampler.Sample(1, 18'000'000, 90'000'000, &sample));
|
||||
EXPECT_TRUE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.thread_ms, 8.0);
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 40.0);
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, SkipsThreadCpuWhenAnotherThreadPresentedLast) {
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
|
||||
ASSERT_TRUE(sampler.Sample(2, 3'000'000, 70'000'000, &sample));
|
||||
EXPECT_FALSE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 20.0);
|
||||
|
||||
// Thread 2 presented last, so its next frame measures from here.
|
||||
ASSERT_TRUE(sampler.Sample(2, 7'000'000, 80'000'000, &sample));
|
||||
EXPECT_TRUE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.thread_ms, 4.0);
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, ClocksReadOutOfOrderDoNotWrap) {
|
||||
// Two presenting threads may read the process clock in one order and take
|
||||
// the lock in the other; a reused thread id restarts its thread clock.
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
|
||||
ASSERT_TRUE(sampler.Sample(1, 4'000'000, 45'000'000, &sample));
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 0.0);
|
||||
EXPECT_FALSE(sample.has_thread);
|
||||
|
||||
// The later process reading stays the baseline.
|
||||
ASSERT_TRUE(sampler.Sample(1, 6'000'000, 60'000'000, &sample));
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 10.0);
|
||||
EXPECT_TRUE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.thread_ms, 2.0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
@@ -312,8 +314,10 @@ TEST(CommandLineTest, UsageContainsEverySupportedOption) {
|
||||
EXPECT_EQ(usage.find("--login"), std::string::npos);
|
||||
EXPECT_EQ(usage.find("--import-cookie"), std::string::npos);
|
||||
EXPECT_NE(usage.find("native sign-in flow"), std::string::npos);
|
||||
EXPECT_NE(usage.find("verified managed x86_64 Roblox payload"),
|
||||
std::string::npos);
|
||||
EXPECT_NE(
|
||||
usage.find("verified managed " +
|
||||
std::string(mocktail::compat::kGuestAbi) + " Roblox payload"),
|
||||
std::string::npos);
|
||||
EXPECT_EQ(usage.find("Normal startup uses rbx_bin/libroblox.so"),
|
||||
std::string::npos);
|
||||
EXPECT_EQ(usage.find("--launch-request-json"), std::string::npos);
|
||||
|
||||
@@ -176,8 +176,10 @@ grep -Fq './scripts/build_flatpak.sh' <<<"${dry_run}" ||
|
||||
Fail 'make flatpak does not invoke the guarded Flatpak builder'
|
||||
grep -Fq -- '--manifest packaging/flatpak/io.github.CoderDayton.nightcap.json' \
|
||||
<<<"${dry_run}" || Fail 'make flatpak lost the canonical manifest'
|
||||
grep -Fq -- '--jobs 4' <<<"${dry_run}" ||
|
||||
grep -Fq -- '--jobs 2' <<<"${dry_run}" ||
|
||||
Fail 'make flatpak does not cap local build parallelism'
|
||||
grep -Fq 'JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"' "${BUILD_HELPER}" ||
|
||||
Fail 'Flatpak helper does not cap local build parallelism'
|
||||
|
||||
mkdir -p -- "${TEMP_DIR}/bin"
|
||||
cat >"${TEMP_DIR}/bin/uname" <<'EOF'
|
||||
|
||||
@@ -8,13 +8,15 @@ set -Eeuo pipefail
|
||||
readonly ROOT="${1:?source root is required}"
|
||||
readonly CMAKE_FILE="${ROOT}/CMakeLists.txt"
|
||||
readonly PKGBUILD="${ROOT}/packaging/arch/PKGBUILD"
|
||||
readonly AUR_STABLE_PKGBUILD="${ROOT}/packaging/aur/mocktail/PKGBUILD"
|
||||
readonly AUR_STABLE_SRCINFO="${ROOT}/packaging/aur/mocktail/.SRCINFO"
|
||||
readonly AUR_BIN_PKGBUILD="${ROOT}/packaging/aur/mocktail-bin/PKGBUILD"
|
||||
readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/mocktail-bin/.SRCINFO"
|
||||
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/mocktail-git/PKGBUILD"
|
||||
readonly AUR_SRCINFO="${ROOT}/packaging/aur/mocktail-git/.SRCINFO"
|
||||
readonly AUR_STABLE_PKGBUILD="${ROOT}/packaging/aur/nightcap/PKGBUILD"
|
||||
readonly AUR_STABLE_SRCINFO="${ROOT}/packaging/aur/nightcap/.SRCINFO"
|
||||
readonly AUR_BIN_PKGBUILD="${ROOT}/packaging/aur/nightcap-bin/PKGBUILD"
|
||||
readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/nightcap-bin/.SRCINFO"
|
||||
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/nightcap-git/PKGBUILD"
|
||||
readonly AUR_SRCINFO="${ROOT}/packaging/aur/nightcap-git/.SRCINFO"
|
||||
readonly AUR_WORKFLOW="${ROOT}/.github/workflows/aur.yml"
|
||||
readonly WORKFLOW="${ROOT}/.github/workflows/packages.yml"
|
||||
readonly RELEASE_WORKFLOW="${ROOT}/.github/workflows/release.yml"
|
||||
readonly README="${ROOT}/README.md"
|
||||
readonly STUB_CMAKE="${ROOT}/stubs/CMakeLists.txt"
|
||||
readonly ANDROID_STUB="${ROOT}/stubs/libandroid_stub.cc"
|
||||
@@ -35,31 +37,39 @@ grep -Fq 'CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON' "${CMAKE_FILE}" ||
|
||||
Fail 'DEB packages do not derive shared-library dependencies'
|
||||
grep -Fq 'CPACK_RPM_PACKAGE_AUTOREQPROV ON' "${CMAKE_FILE}" ||
|
||||
Fail 'RPM packages do not derive runtime requirements'
|
||||
grep -Fq 'pkgname=mocktail' "${PKGBUILD}" ||
|
||||
grep -Fq 'set(MOCKTAIL_PACKAGE_VERSION' "${CMAKE_FILE}" ||
|
||||
Fail 'native package version cannot be set from the release tag'
|
||||
grep -Fq 'https://github.com/CoderDayton/nightcap' "${CMAKE_FILE}" ||
|
||||
Fail 'native packages point at the upstream project page'
|
||||
grep -Fq 'pkgname=nightcap' "${PKGBUILD}" ||
|
||||
Fail 'Arch package name is not stable'
|
||||
grep -Fq "provides=('mocktail')" "${PKGBUILD}" ||
|
||||
Fail 'Arch package does not provide the upstream package name'
|
||||
grep -Fq "conflicts=('mocktail')" "${PKGBUILD}" ||
|
||||
Fail 'Arch package can be installed beside upstream Mocktail'
|
||||
grep -Fq "'sdl3>=3.4'" "${PKGBUILD}" ||
|
||||
Fail 'Arch package does not enforce the SDL minimum'
|
||||
grep -Fq "'libplacebo'" "${PKGBUILD}" ||
|
||||
Fail 'Arch package does not declare the graphics composition dependency'
|
||||
grep -Fq 'pkgname=mocktail-git' "${AUR_PKGBUILD}" ||
|
||||
grep -Fq 'pkgname=nightcap-git' "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package name is not stable'
|
||||
grep -Fq 'pkgname=mocktail' "${AUR_STABLE_PKGBUILD}" ||
|
||||
grep -Fq 'pkgname=nightcap' "${AUR_STABLE_PKGBUILD}" ||
|
||||
Fail 'AUR source package name is not stable'
|
||||
grep -Fq '#tag=${pkgver}' "${AUR_STABLE_PKGBUILD}" ||
|
||||
grep -Fq '#tag=v${pkgver}' "${AUR_STABLE_PKGBUILD}" ||
|
||||
Fail 'AUR source package does not pin its Git tag'
|
||||
grep -Fq "'vulkan-headers'" "${AUR_STABLE_PKGBUILD}" ||
|
||||
Fail 'AUR source package does not use the packaged Vulkan headers'
|
||||
grep -Fq 'pkgbase = mocktail' "${AUR_STABLE_SRCINFO}" ||
|
||||
grep -Fq 'pkgbase = nightcap' "${AUR_STABLE_SRCINFO}" ||
|
||||
Fail 'AUR source package has no generated .SRCINFO metadata'
|
||||
grep -Fq 'pkgname=mocktail-bin' "${AUR_BIN_PKGBUILD}" ||
|
||||
grep -Fq 'pkgname=nightcap-bin' "${AUR_BIN_PKGBUILD}" ||
|
||||
Fail 'AUR binary package name is not stable'
|
||||
grep -Fq 'pkgbase = mocktail-bin' "${AUR_BIN_SRCINFO}" ||
|
||||
grep -Fq 'pkgbase = nightcap-bin' "${AUR_BIN_SRCINFO}" ||
|
||||
Fail 'AUR binary package has no generated .SRCINFO metadata'
|
||||
grep -Fq -- \
|
||||
"'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'" \
|
||||
"'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'" \
|
||||
"${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR package does not build from the upstream Git repository'
|
||||
grep -Fq "provides=('mocktail')" "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR package does not build from this project Git repository'
|
||||
grep -Fq "provides=('mocktail' 'nightcap')" "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package does not provide the stable package name'
|
||||
grep -Fq "'vulkan-headers'" "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package does not use the packaged Vulkan headers'
|
||||
@@ -76,8 +86,26 @@ grep -Fq -- \
|
||||
'-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/' \
|
||||
"${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR package embeds a build-tree compatibility manifest path'
|
||||
grep -Fq 'pkgbase = mocktail-git' "${AUR_SRCINFO}" ||
|
||||
grep -Fq 'pkgbase = nightcap-git' "${AUR_SRCINFO}" ||
|
||||
Fail 'AUR package has no generated .SRCINFO metadata'
|
||||
grep -Fq 'git describe' "${AUR_PKGBUILD}" ||
|
||||
Fail 'AUR VCS package does not version from the release tag'
|
||||
grep -Fq 'git describe' "${AUR_WORKFLOW}" ||
|
||||
Fail 'AUR workflow does not version from the release tag'
|
||||
for pkgbuild in "${PKGBUILD}" "${AUR_STABLE_PKGBUILD}" "${AUR_BIN_PKGBUILD}"; do
|
||||
grep -Fqx 'pkgver=0.3.1' "${pkgbuild}" ||
|
||||
Fail "package version does not match the release being prepared: ${pkgbuild}"
|
||||
done
|
||||
|
||||
# Nightcap keeps its AUR recipes in the tree. Nothing publishes them.
|
||||
for unexpected in \
|
||||
'aur.archlinux.org' \
|
||||
'AUR_SSH_PRIVATE_KEY' \
|
||||
'push origin'; do
|
||||
if grep -Fq -- "${unexpected}" "${AUR_WORKFLOW}"; then
|
||||
Fail "AUR workflow should not publish: ${unexpected}"
|
||||
fi
|
||||
done
|
||||
grep -Fq 'LINKER:--no-as-needed' "${STUB_CMAKE}" ||
|
||||
Fail 'system shims can lose their host libc dependencies'
|
||||
grep -Fq 'MOCKTAIL_MINIZIP_HAS_STREAM_TELL' "${STUB_CMAKE}" ||
|
||||
@@ -116,6 +144,29 @@ for unexpected in \
|
||||
fi
|
||||
done
|
||||
|
||||
for expected in \
|
||||
'-G DEB' \
|
||||
'-G RPM' \
|
||||
'makepkg --dir' \
|
||||
'-DMOCKTAIL_PACKAGE_NAME=nightcap' \
|
||||
'-DMOCKTAIL_PACKAGE_VERSION=' \
|
||||
'Nightcap-x86_64.AppImage' \
|
||||
'gh release upload'; do
|
||||
grep -Fq -- "${expected}" "${RELEASE_WORKFLOW}" ||
|
||||
Fail "release workflow is missing: ${expected}"
|
||||
done
|
||||
|
||||
# Nightcap publishes its packages on the release page only. Pushing them to
|
||||
# APT, RPM or AUR repositories is out of scope for this workflow.
|
||||
for unexpected in \
|
||||
'createrepo' \
|
||||
'reprepro' \
|
||||
'aur.archlinux.org'; do
|
||||
if grep -Fq -- "${unexpected}" "${RELEASE_WORKFLOW}"; then
|
||||
Fail "release workflow should not publish to a repository: ${unexpected}"
|
||||
fi
|
||||
done
|
||||
|
||||
grep -Fq 'Ubuntu 26.04+' "${README}" ||
|
||||
Fail 'README has no Ubuntu source dependency guide'
|
||||
grep -Fq 'Arch Linux' "${README}" ||
|
||||
|
||||
+37
-19
@@ -13,6 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "compat/build_profile.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "compat/host_abi_profile.h"
|
||||
#include "update/apkpure_provider.h"
|
||||
#include "update/compatibility_catalog.h"
|
||||
@@ -521,6 +522,18 @@ TEST(ShippedMetadataTest, ReferenceSidecarDescribesASupportedProfile) {
|
||||
const HostAbiSidecarIdentity identity = ReadHostAbiSidecarIdentity(
|
||||
root / "config/roblox_host_abi_reference.json");
|
||||
ASSERT_TRUE(identity) << identity.error;
|
||||
std::string sidecar_abi = "x86_64";
|
||||
for (const auto& entry :
|
||||
nlohmann::json::parse(
|
||||
ReadFile(root / "config/roblox_compatibility.json"))
|
||||
.at("profiles")) {
|
||||
if (entry.value("elf_build_id", "") == identity.elf_build_id) {
|
||||
sidecar_abi = entry.value("abi", "x86_64");
|
||||
}
|
||||
}
|
||||
if (sidecar_abi != compat::kGuestAbi) {
|
||||
GTEST_SKIP() << "shipped reference sidecar targets " << sidecar_abi;
|
||||
}
|
||||
|
||||
const auto supported = std::find_if(
|
||||
catalog.profiles.begin(), catalog.profiles.end(),
|
||||
@@ -561,6 +574,11 @@ TEST(ShippedMetadataTest, DefaultPayloadHasABuiltinProfileAndMatchingReference)
|
||||
|
||||
const auto payload = nlohmann::json::parse(
|
||||
ReadFile(root / "config/roblox_payload.json"));
|
||||
if (payload.at("abi").get_ref<const std::string&>() != compat::kGuestAbi) {
|
||||
EXPECT_NE(compat::FindHostAbiProfile(preferred->elf_build_id), nullptr);
|
||||
GTEST_SKIP() << "shipped default payload targets "
|
||||
<< payload.at("abi").get<std::string>();
|
||||
}
|
||||
EXPECT_EQ(payload.at("version_name"), preferred->version_name);
|
||||
EXPECT_EQ(payload.at("version_code"), preferred->version_code);
|
||||
EXPECT_EQ(payload.at("elf_build_id"), preferred->elf_build_id);
|
||||
@@ -646,7 +664,7 @@ TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
|
||||
const std::filesystem::path restaged = temporary.root() / "restaged";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -662,8 +680,8 @@ TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
{"source", "apk-pure-native"},
|
||||
{"imported_at", "2026-09-04T03:49:16Z"},
|
||||
@@ -707,7 +725,7 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -725,8 +743,8 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -770,7 +788,7 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -788,8 +806,8 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -840,7 +858,7 @@ TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -856,8 +874,8 @@ TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -900,7 +918,7 @@ TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -916,8 +934,8 @@ TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -961,7 +979,7 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "new-native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "new-base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "new-split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "new-split-apk");
|
||||
Write(prepared / "assets/content/fixture", "new-asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -975,13 +993,13 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
|
||||
{"package", "com.roblox.client"},
|
||||
{"version_name", "2.732.1043"},
|
||||
{"version_code", 2814},
|
||||
{"abi", "x86_64"},
|
||||
{"abi", std::string(compat::kGuestAbi)},
|
||||
{"elf_build_id", kBuildId},
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
|
||||
@@ -164,6 +164,28 @@ TEST(SdlEventConverterTest, ConvertsFocusWithoutOwningTheSdlQueue) {
|
||||
EXPECT_TRUE(focus->focused);
|
||||
}
|
||||
|
||||
TEST(SdlEventConverterTest, PreservesHostWheelDirectionAndPrecision) {
|
||||
SDL_Event source{};
|
||||
source.type = SDL_EVENT_MOUSE_WHEEL;
|
||||
source.wheel.mouse_x = 120.0f;
|
||||
source.wheel.mouse_y = 240.0f;
|
||||
platform::PlatformEvent event;
|
||||
auto* non_owning_window = reinterpret_cast<SDL_Window*>(0x1);
|
||||
|
||||
for (const auto direction : {SDL_MOUSEWHEEL_NORMAL, SDL_MOUSEWHEEL_FLIPPED}) {
|
||||
source.wheel.direction = direction;
|
||||
source.wheel.x = 0.25f;
|
||||
source.wheel.y = -1.5f;
|
||||
ASSERT_TRUE(platform::ConvertSdlEvent(non_owning_window, source, &event));
|
||||
const auto* wheel = std::get_if<platform::MouseWheelEvent>(&event.payload);
|
||||
ASSERT_NE(wheel, nullptr);
|
||||
EXPECT_FLOAT_EQ(wheel->delta_x, 0.25f);
|
||||
EXPECT_FLOAT_EQ(wheel->delta_y, -1.5f);
|
||||
EXPECT_FLOAT_EQ(wheel->mouse_x, 120.0f);
|
||||
EXPECT_FLOAT_EQ(wheel->mouse_y, 240.0f);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(SdlEventConverterTest, RejectsInvalidArgumentsAndUnsupportedEvents) {
|
||||
SDL_Event source{};
|
||||
source.type = SDL_EVENT_USER;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
using Json = nlohmann::json;
|
||||
|
||||
// Distinct from the production values so tests prove the bridge uses the
|
||||
// names the engine reports instead of hard-coded strings.
|
||||
constexpr char kProtocol[] = "TestAccountProtocol";
|
||||
constexpr char kAvailableMethod[] = "TestIntegrityAvailable";
|
||||
constexpr char kTokenMethod[] = "TestIntegrityToken";
|
||||
constexpr char kSupportKey[] = "testSupport";
|
||||
constexpr char kTokenKey[] = "testToken";
|
||||
constexpr char kResultKey[] = "testResult";
|
||||
constexpr char kTokenRequest[] =
|
||||
R"({"requestHash":"opaque-hash","timeoutMillis":60000})";
|
||||
|
||||
std::string Copy(JNIEnv* env, jstring value) {
|
||||
if (!value) return {};
|
||||
const char* chars = env->GetStringUTFChars(value, nullptr);
|
||||
const std::string copy = chars ? chars : "";
|
||||
if (chars) env->ReleaseStringUTFChars(value, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
struct Probe {
|
||||
jnivm::VM* vm = nullptr;
|
||||
std::map<std::string, jobject> handlers;
|
||||
std::vector<std::string> native_cleared;
|
||||
int handlers_created = 0;
|
||||
int handlers_cleared = 0;
|
||||
int fail_handler_at = 0;
|
||||
bool null_token_key = false;
|
||||
};
|
||||
|
||||
Probe* g_probe = nullptr;
|
||||
|
||||
void Prepare(void* context) {
|
||||
static_cast<jnivm::VM*>(context)->RestoreFunctions();
|
||||
}
|
||||
|
||||
jstring ProtocolName(JNIEnv* env, jclass) { return env->NewStringUTF(kProtocol); }
|
||||
jstring AvailableMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(kAvailableMethod);
|
||||
}
|
||||
jstring TokenMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(kTokenMethod);
|
||||
}
|
||||
jstring SupportKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(kSupportKey);
|
||||
}
|
||||
jstring TokenKey(JNIEnv* env, jclass) {
|
||||
return g_probe->null_token_key ? nullptr : env->NewStringUTF(kTokenKey);
|
||||
}
|
||||
jstring ResultKey(JNIEnv* env, jclass) { return env->NewStringUTF(kResultKey); }
|
||||
|
||||
void SetHandler(JNIEnv* env, jobject, jstring protocol, jstring method,
|
||||
jobject handler) {
|
||||
EXPECT_EQ(Copy(env, protocol), kProtocol);
|
||||
g_probe->handlers[Copy(env, method)] = handler;
|
||||
}
|
||||
|
||||
void ClearHandler(JNIEnv* env, jobject, jstring protocol, jstring method) {
|
||||
EXPECT_EQ(Copy(env, protocol), kProtocol);
|
||||
g_probe->native_cleared.push_back(Copy(env, method));
|
||||
}
|
||||
|
||||
jobject CreateHandler(void* context, std::shared_ptr<void> target,
|
||||
std::string (*run)(void*, JNIEnv*, jstring)) {
|
||||
auto* probe = static_cast<Probe*>(context);
|
||||
if (++probe->handlers_created == probe->fail_handler_at) return nullptr;
|
||||
return probe->vm->CreateMessageBusRequestHandler(
|
||||
std::move(target), jnivm::MessageBusRequestHandlerCallbacks{run});
|
||||
}
|
||||
|
||||
void ClearHandlerObject(void* context, jobject handler) {
|
||||
auto* probe = static_cast<Probe*>(context);
|
||||
++probe->handlers_cleared;
|
||||
probe->vm->ClearMessageBusRequestHandler(handler);
|
||||
}
|
||||
|
||||
RobloxAccountProtocolSymbols Symbols() {
|
||||
return {ProtocolName, AvailableMethod, TokenMethod, SupportKey,
|
||||
TokenKey, ResultKey, SetHandler, ClearHandler};
|
||||
}
|
||||
|
||||
class RobloxAccountBridgeTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
probe.vm = &vm;
|
||||
g_probe = &probe;
|
||||
env = vm.GetJNIEnv();
|
||||
jclass cls =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
bus = env->AllocObject(cls);
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
void TearDown() override {
|
||||
bridge.reset();
|
||||
env->DeleteLocalRef(bus);
|
||||
g_probe = nullptr;
|
||||
}
|
||||
Status Initialize(RobloxAccountProtocolSymbols symbols = Symbols()) {
|
||||
bridge = std::make_unique<RobloxAccountBridge>(
|
||||
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare}, symbols,
|
||||
RobloxAccountProtocolObjects{bus, &probe, CreateHandler,
|
||||
ClearHandlerObject});
|
||||
return bridge->Initialize();
|
||||
}
|
||||
Json Request(const char* method, const char* payload) {
|
||||
const auto found = probe.handlers.find(method);
|
||||
EXPECT_NE(found, probe.handlers.end()) << method;
|
||||
if (found == probe.handlers.end()) return nullptr;
|
||||
jstring message = env->NewStringUTF(payload);
|
||||
jstring response =
|
||||
vm.DispatchMessageBusRequestHandler(found->second, env, message);
|
||||
env->DeleteLocalRef(message);
|
||||
const std::string body = Copy(env, response);
|
||||
if (response) env->DeleteLocalRef(response);
|
||||
return Json::parse(body, nullptr, false);
|
||||
}
|
||||
|
||||
jnivm::VM vm;
|
||||
Probe probe;
|
||||
JNIEnv* env = nullptr;
|
||||
jobject bus = nullptr;
|
||||
std::unique_ptr<RobloxAccountBridge> bridge;
|
||||
};
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, RegistersBothHandlersUnderEngineNames) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(probe.handlers.size(), 2u);
|
||||
EXPECT_TRUE(probe.handlers.count(kAvailableMethod));
|
||||
EXPECT_TRUE(probe.handlers.count(kTokenMethod));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, ReportsDeviceIntegrityUnsupported) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(Request(kAvailableMethod, "{}"), (Json{{kSupportKey, false}}));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, AnswersTokenRequestWithoutToken) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(Request(kTokenMethod, kTokenRequest),
|
||||
(Json{{kTokenKey, ""}, {kResultKey, "TOKEN_PROVIDER_UNINITIALIZED"}}));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, AnswersMalformedTokenRequestWithoutToken) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(Request(kTokenMethod, "not json"),
|
||||
(Json{{kTokenKey, ""}, {kResultKey, "TOKEN_PROVIDER_UNINITIALIZED"}}));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, RejectsIncompletePrerequisites) {
|
||||
RobloxAccountProtocolSymbols symbols = Symbols();
|
||||
symbols.get_token_key = nullptr;
|
||||
|
||||
EXPECT_FALSE(Initialize(symbols).ok());
|
||||
EXPECT_TRUE(probe.handlers.empty());
|
||||
EXPECT_EQ(probe.handlers_created, 0);
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, FailsWithoutRegisteringWhenEngineNameIsNull) {
|
||||
probe.null_token_key = true;
|
||||
|
||||
EXPECT_FALSE(Initialize().ok());
|
||||
EXPECT_TRUE(probe.handlers.empty());
|
||||
EXPECT_EQ(probe.handlers_created, 0);
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, HandlerCreationFailureReleasesEarlierHandler) {
|
||||
probe.fail_handler_at = 2;
|
||||
|
||||
EXPECT_FALSE(Initialize().ok());
|
||||
EXPECT_EQ(probe.handlers_cleared, 1);
|
||||
for (const auto& [method, handler] : probe.handlers) {
|
||||
EXPECT_NE(std::find(probe.native_cleared.begin(),
|
||||
probe.native_cleared.end(), method),
|
||||
probe.native_cleared.end())
|
||||
<< method;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, InitializeTwiceFails) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_FALSE(bridge->Initialize().ok());
|
||||
EXPECT_EQ(probe.handlers_created, 2);
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, ShutdownClearsBothHandlersOnce) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
EXPECT_EQ(probe.handlers_cleared, 2);
|
||||
EXPECT_EQ(probe.native_cleared,
|
||||
(std::vector<std::string>{kAvailableMethod, kTokenMethod}));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -50,6 +50,8 @@ struct Probe {
|
||||
int permission_disconnects = 0;
|
||||
int permission_handlers_cleared = 0;
|
||||
std::map<std::string, jobject> permission_handlers;
|
||||
std::map<std::string, jobject> account_handlers;
|
||||
int account_handlers_cleared = 0;
|
||||
std::string permission_response;
|
||||
int browser_bindings = 0;
|
||||
int browser_disconnects = 0;
|
||||
@@ -266,6 +268,45 @@ nlohmann::json QueryMicrophone(Probe* probe) {
|
||||
}
|
||||
|
||||
void SetRequestHandler(JNIEnv*, jobject, jstring, jstring, jobject) {}
|
||||
|
||||
std::string CopyJavaString(JNIEnv* env, jstring value) {
|
||||
const char* chars = value ? env->GetStringUTFChars(value, nullptr) : nullptr;
|
||||
const std::string copy = chars ? chars : "";
|
||||
if (chars) env->ReleaseStringUTFChars(value, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
jstring AccountProtocolName(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("AccountProtocol");
|
||||
}
|
||||
jstring AccountAvailableMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("DeviceIntegrityAvailable");
|
||||
}
|
||||
jstring AccountTokenMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("GetIntegrityToken");
|
||||
}
|
||||
jstring AccountSupportKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("support");
|
||||
}
|
||||
jstring AccountTokenKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("token");
|
||||
}
|
||||
jstring AccountResultKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("result");
|
||||
}
|
||||
void SetAccountHandler(JNIEnv* env, jobject, jstring, jstring method,
|
||||
jobject handler) {
|
||||
g_probe->account_handlers[CopyJavaString(env, method)] = handler;
|
||||
}
|
||||
void ClearAccountHandler(JNIEnv*, jobject, jstring, jstring) {
|
||||
++g_probe->account_handlers_cleared;
|
||||
}
|
||||
|
||||
RobloxAccountProtocolSymbols AccountSymbols() {
|
||||
return {AccountProtocolName, AccountAvailableMethod, AccountTokenMethod,
|
||||
AccountSupportKey, AccountTokenKey, AccountResultKey,
|
||||
SetAccountHandler, ClearAccountHandler};
|
||||
}
|
||||
void ClearNativeRequestHandler(JNIEnv*, jobject, jstring, jstring) {}
|
||||
void PublishRaw(JNIEnv*, jobject, jstring, jstring) {}
|
||||
void BroadcastDataModelFocus(JNIEnv*, jclass, jstring, jstring, jstring) {}
|
||||
@@ -911,6 +952,49 @@ TEST(RobloxExperienceCompositionTest,
|
||||
EXPECT_EQ(probe.browser_disconnects, 4);
|
||||
EXPECT_EQ(probe.browser_releases, 4);
|
||||
EXPECT_EQ(probe.browser_callbacks_cleared, 4);
|
||||
EXPECT_TRUE(probe.account_handlers.empty());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxExperienceCompositionTest,
|
||||
AnswersDeviceIntegrityAsUnsupportedWithPlatformProtocols) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
for (const char* class_name : {
|
||||
"com/roblox/protocols/webview/WebViewProtocol",
|
||||
"com/roblox/universalapp/messagebus/MessageBus",
|
||||
"com/roblox/universalapp/messagebus/Connection",
|
||||
"com/roblox/engine/jni/memstorage/MemStorage",
|
||||
"com/roblox/engine/jni/memstorage/Connection",
|
||||
"com/roblox/engine/jni/memstorage/Callback",
|
||||
}) {
|
||||
vm.RegisterClass(class_name);
|
||||
}
|
||||
RobloxExperienceComposition composition(
|
||||
{vm.GetJavaVM(), &vm, Prepare}, {},
|
||||
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
|
||||
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
|
||||
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
|
||||
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
|
||||
GetWebViewCloseId, SignalWebViewJavascriptCallback,
|
||||
UpdateCookieSetHandler},
|
||||
BrowserServiceSymbols(), PermissionsSymbols(), {}, JniFactory(&probe),
|
||||
{}, {}, nullptr, {}, {}, false, false, AccountSymbols());
|
||||
|
||||
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
|
||||
ASSERT_EQ(probe.account_handlers.size(), 2u);
|
||||
JNIEnv* env = vm.GetJNIEnv();
|
||||
jstring request = env->NewStringUTF("{}");
|
||||
jstring response = vm.DispatchMessageBusRequestHandler(
|
||||
probe.account_handlers.at("DeviceIntegrityAvailable"), env, request);
|
||||
EXPECT_EQ(nlohmann::json::parse(CopyJavaString(env, response)),
|
||||
(nlohmann::json{{"support", false}}));
|
||||
env->DeleteLocalRef(request);
|
||||
if (response) env->DeleteLocalRef(response);
|
||||
|
||||
EXPECT_TRUE(composition.Shutdown().ok());
|
||||
EXPECT_EQ(probe.account_handlers_cleared, 2);
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
@@ -1023,7 +1107,7 @@ TEST(RobloxExperienceCompositionTest,
|
||||
class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test {
|
||||
protected:
|
||||
struct Helper {
|
||||
Helper() {
|
||||
explicit Helper(const char* before_ready = "") {
|
||||
char pattern[] = "/tmp/mocktail_surface_close_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
if (created == nullptr) return;
|
||||
@@ -1035,6 +1119,7 @@ import socket, sys
|
||||
sys.stdin.buffer.read()
|
||||
s = socket.socket(fileno=198)
|
||||
s.settimeout(5)
|
||||
)PY" << before_ready << R"PY(
|
||||
s.send(b'MWVE' + bytes([1, 3, 0, 0]) + bytes(4))
|
||||
while True:
|
||||
packet = s.recv(1024 * 1024)
|
||||
@@ -1071,6 +1156,14 @@ while True:
|
||||
++static_cast<ExitProbe*>(context)->calls;
|
||||
}
|
||||
|
||||
static Status Open(RobloxExperienceComposition* composition,
|
||||
const std::shared_ptr<ExitProbe>& probe) {
|
||||
return composition->OpenWebSurface(
|
||||
"https://www.roblox.com/challenge/cdn/hybrid", "webview",
|
||||
RobloxExperienceComposition::WebSurfaceRoute::kWebView,
|
||||
{probe, &RobloxExperienceCompositionWebSurfaceTest::ObserveExit}, {});
|
||||
}
|
||||
|
||||
static std::unique_ptr<RobloxExperienceComposition> MakeComposition(
|
||||
const SecureRobloxCredential* credential = nullptr) {
|
||||
return std::make_unique<RobloxExperienceComposition>(
|
||||
@@ -1132,6 +1225,61 @@ while True:
|
||||
uint64_t process_generation) {
|
||||
composition->HandleWebSurfaceExit(process_generation);
|
||||
}
|
||||
|
||||
static void MarkClosesWhenChallengeSolved(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
composition->web_surface_close_when_challenge_solved_ = true;
|
||||
}
|
||||
|
||||
static bool SolvedChallengeClosePending(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
return composition->solved_challenge_close_deadline_.has_value();
|
||||
}
|
||||
|
||||
static void ExpireSolvedChallengeGrace(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
if (composition->solved_challenge_close_deadline_.has_value()) {
|
||||
composition->solved_challenge_close_deadline_ =
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
|
||||
static std::string ChallengeSuccess() {
|
||||
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"data":{"captchaData":{}},)"
|
||||
R"("feature":"CaptchaSuccess"}},"callbackID":"challenge-1"})";
|
||||
}
|
||||
|
||||
static std::unique_ptr<RobloxExperienceComposition> MakeWebViewComposition(
|
||||
jnivm::VM* vm, Probe* probe) {
|
||||
for (const char* class_name : {
|
||||
"com/roblox/protocols/webview/WebViewProtocol",
|
||||
"com/roblox/universalapp/messagebus/MessageBus",
|
||||
"com/roblox/universalapp/messagebus/Connection",
|
||||
"com/roblox/engine/jni/memstorage/MemStorage",
|
||||
"com/roblox/engine/jni/memstorage/Connection",
|
||||
"com/roblox/engine/jni/memstorage/Callback",
|
||||
}) {
|
||||
vm->RegisterClass(class_name);
|
||||
}
|
||||
return std::make_unique<RobloxExperienceComposition>(
|
||||
JniEnvironmentProvider{vm->GetJavaVM(), vm, Prepare},
|
||||
RobloxExperienceMessageBusSymbols{},
|
||||
RobloxWebViewMessageBusSymbols{
|
||||
GetWebViewOpenId, GetWebViewHandleWindowCloseId,
|
||||
GetWebViewProtocolName, GetWebViewAvailabilityId,
|
||||
GetWebViewMessageId, InitializeWebViewProtocol, Subscribe,
|
||||
Disconnect, SetRequestHandler, ClearNativeRequestHandler,
|
||||
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
|
||||
GetWebViewCloseId, SignalWebViewJavascriptCallback,
|
||||
UpdateCookieSetHandler},
|
||||
BrowserServiceSymbols(), PermissionsSymbols(),
|
||||
RobloxGameSessionSymbols{}, JniFactory(probe),
|
||||
RobloxFreshLaunchPresentBoundary{});
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
@@ -1278,6 +1426,164 @@ TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
EXPECT_EQ(probe->calls, 1);
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
WaitsForSlowHelperStartupBeforeRejectingSurface) {
|
||||
Helper helper("import time\ntime.sleep(3.5)\n");
|
||||
ASSERT_FALSE(helper.path.empty());
|
||||
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
|
||||
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
|
||||
auto composition = MakeComposition(&credential);
|
||||
auto probe = std::make_shared<ExitProbe>();
|
||||
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = Open(composition.get(), probe);
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
|
||||
|
||||
EXPECT_TRUE(status.ok()) << status.message();
|
||||
const std::string marker = "Roblox web surface launched in ";
|
||||
const size_t at = log.find(marker);
|
||||
ASSERT_NE(at, std::string::npos) << log;
|
||||
EXPECT_GE(std::stol(log.substr(at + marker.size())), 3500) << log;
|
||||
EXPECT_TRUE(Close(composition.get()).ok());
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ReportsElapsedStartupTimeWhenHelperNeverBecomesReady) {
|
||||
Helper helper("sys.exit(0)\n");
|
||||
ASSERT_FALSE(helper.path.empty());
|
||||
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
|
||||
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
|
||||
auto composition = MakeComposition(&credential);
|
||||
auto probe = std::make_shared<ExitProbe>();
|
||||
|
||||
const Status status = Open(composition.get(), probe);
|
||||
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
|
||||
|
||||
ASSERT_FALSE(status.ok());
|
||||
EXPECT_NE(status.message().find("did not become ready"), std::string::npos)
|
||||
<< status.message();
|
||||
EXPECT_NE(status.message().find(" ms of 5000 ms"), std::string::npos)
|
||||
<< status.message();
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ForwardsSolvedChallengeAndLeavesClosingToRoblox) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
const std::string success = ChallengeSuccess();
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid,
|
||||
success})
|
||||
.ok());
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, success);
|
||||
EXPECT_TRUE(SolvedChallengeClosePending(composition.get()));
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
ASSERT_TRUE(Close(composition.get()).ok());
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
ExpireSolvedChallengeGrace(composition.get());
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ClosesSolvedChallengeWhenRobloxLeavesItOpen) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kExecuteRoblox,
|
||||
ChallengeSuccess()})
|
||||
.ok());
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
ExpireSolvedChallengeGrace(composition.get());
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = composition->DrainPlatformEvents();
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
ASSERT_TRUE(status.ok()) << status.message();
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_NE(log.find("closing solved challenge window"), std::string::npos)
|
||||
<< log;
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ClosesSolvedCaptchaWindowAfterForwardingSuccess) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
MarkClosesWhenChallengeSolved(composition.get());
|
||||
|
||||
const std::string success = ChallengeSuccess();
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = RouteCurrentEvent(
|
||||
composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid, success});
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
ASSERT_TRUE(status.ok()) << status.message();
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, success);
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
EXPECT_NE(log.find("closing solved captcha window"), std::string::npos)
|
||||
<< log;
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
UnsolvedChallengeEventsKeepWindowOpen) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
const std::string shown =
|
||||
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"feature":"CaptchaShown"}},"callbackID":"c"})";
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid,
|
||||
shown})
|
||||
.ok());
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, shown);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
EXPECT_TRUE(Close(composition.get()).ok());
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
namespace {} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -325,7 +325,7 @@ TEST_F(RobloxInputNativeAdapterTest, CallsExactExportedJniSignatures) {
|
||||
(std::vector<double>{90.0, 190.0, 4.0, -2.0}));
|
||||
EXPECT_EQ(probe_.calls[1].name, "mouse-button");
|
||||
EXPECT_EQ(probe_.calls[1].arguments,
|
||||
(std::vector<double>{100.0, 200.0, JNI_TRUE, 3.0}));
|
||||
(std::vector<double>{100.0, 200.0, JNI_TRUE, 2.0}));
|
||||
EXPECT_EQ(probe_.calls[2].name, "touch");
|
||||
EXPECT_EQ(probe_.calls[2].arguments,
|
||||
(std::vector<double>{0.0, 320.0, 360.0, 0.0, 1280.0, 720.0}));
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user