mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 19:00:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c523048518 | ||
|
|
ffe3fba8af | ||
|
|
5d475e03d8 | ||
|
|
240dc1ee63 | ||
|
|
569980ecf2 | ||
|
|
2f80333c5d | ||
|
|
be7d542d49 | ||
|
|
266f917f49 | ||
|
|
859f8f2b8f | ||
|
|
8447bf3f1a | ||
|
|
b71873f2a5 | ||
|
|
92c7d7b12b | ||
|
|
cc3893d422 | ||
|
|
a6fdb5b0cd | ||
|
|
3890efbaaa | ||
|
|
9f7b09e197 | ||
|
|
3284cf9fc1 | ||
|
|
035c7fe1d4 | ||
|
|
d5f9c91358 | ||
|
|
0cc9ab2e32 | ||
|
|
7127ad1bf7 | ||
|
|
54a302dc27 | ||
|
|
23a5c96e3d | ||
|
|
ae417490f1 | ||
|
|
35298050b8 | ||
|
|
703af11d17 | ||
|
|
3a26ecc83e | ||
|
|
278d15e41a | ||
|
|
968df46605 | ||
|
|
409d8a6319 | ||
|
|
9812fbceab | ||
|
|
61a467cadc | ||
|
|
497481e8fa | ||
|
|
5f2be59192 | ||
|
|
9bd5f97063 | ||
|
|
8f63b9deba | ||
|
|
272f61688b | ||
|
|
f797ec7e40 | ||
|
|
02c8c6ca83 | ||
|
|
5af66cf8fc | ||
|
|
b236c59b57 | ||
|
|
386476eda6 | ||
|
|
b6407e7d3a | ||
|
|
21efe88b22 | ||
|
|
0f635abc25 | ||
|
|
2f179068b3 | ||
|
|
81b490e196 | ||
|
|
f2168e2f98 | ||
|
|
64a564f292 | ||
|
|
3728afa0d7 | ||
|
|
931543e976 | ||
|
|
a2a210da87 | ||
|
|
b289e7933e | ||
|
|
497cf9ea6c | ||
|
|
0b7326d8d1 | ||
|
|
e312ab6667 | ||
|
|
b8e535f6f9 | ||
|
|
c0f6787cc6 | ||
|
|
f0e0229ec5 | ||
|
|
56749bbdc8 | ||
|
|
9da2c02836 | ||
|
|
b5eac34e0f | ||
|
|
e4c64c2fec | ||
|
|
ba6287250f | ||
|
|
4b437f623f | ||
|
|
e161fecfc5 | ||
|
|
dcf22a144b | ||
|
|
0bc8823644 | ||
|
|
f9a48cfe83 | ||
|
|
52721bf06c | ||
|
|
9bbd2a1ebf |
No files matched your search
@@ -0,0 +1,102 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem in Mocktail
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Mocktail version
|
||||
description: Provide the release version, package version, or commit hash.
|
||||
placeholder: "For example: v0.4.0 or 1a2b3c4"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
label: System information
|
||||
description: Include your distribution, desktop session, GPU, and graphics driver when relevant.
|
||||
placeholder: "For example: Arch Linux, KDE Plasma 6 on Wayland, AMD RX 6600, Mesa 26.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: installation
|
||||
attributes:
|
||||
label: Installation method
|
||||
description: Tell us how Mocktail was installed or built.
|
||||
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the problem and its impact.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: List the smallest reliable sequence of actions that triggers the problem.
|
||||
placeholder: |
|
||||
1. Start Mocktail with ...
|
||||
2. Open ...
|
||||
3. Select ...
|
||||
4. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Explain what you expected to happen instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Latest log:
|
||||
|
||||
- Flatpak: `~/.var/app/space.bigrat.mocktail/.local/state/mocktail/logs/latest.log`
|
||||
- Other installs: `~/.local/state/mocktail/logs/latest.log`
|
||||
|
||||
Older logs are in the `sessions` directory next to `latest.log`.
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Paste the relevant part of latest.log or the terminal output.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add screenshots, videos, workarounds, or any other useful details.
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched the existing issues and did not find a duplicate.
|
||||
required: true
|
||||
@@ -0,0 +1,2 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links: []
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement or a new capability for Mocktail
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or use case
|
||||
description: What are you trying to do, and what makes it difficult today?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe how you would like Mocktail to behave.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Describe any workarounds or alternative designs you have considered.
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add mockups, screenshots, links, or implementation notes if useful.
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched the existing issues and did not find a duplicate.
|
||||
required: true
|
||||
@@ -0,0 +1,3 @@
|
||||
<!-- 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". -->
|
||||
|
||||
Closes #
|
||||
+27
-17
@@ -1,4 +1,4 @@
|
||||
name: Publish AUR development package
|
||||
name: Publish AUR packages
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,12 +9,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: aur-mocktail-git
|
||||
group: aur-packages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish mocktail-git
|
||||
name: Publish AUR packages
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -87,18 +87,28 @@ jobs:
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile=$known_hosts"
|
||||
aur_dir="$RUNNER_TEMP/mocktail-git-aur"
|
||||
git clone --branch master --single-branch \
|
||||
ssh://aur@aur.archlinux.org/mocktail-git.git "$aur_dir"
|
||||
install -m 0644 packaging/aur/mocktail-git/PKGBUILD "$aur_dir/PKGBUILD"
|
||||
install -m 0644 packaging/aur/mocktail-git/.SRCINFO "$aur_dir/.SRCINFO"
|
||||
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 -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 'mocktail-git %s is already published\n' "$AUR_PKGVER"
|
||||
exit 0
|
||||
fi
|
||||
git -C "$aur_dir" commit -m "Update to ${AUR_PKGVER}"
|
||||
git -C "$aur_dir" push origin HEAD:master
|
||||
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'
|
||||
@@ -52,6 +52,12 @@ jobs:
|
||||
find config packaging/flatpak -type f -name '*.json' -print0 \
|
||||
| xargs -0 -n1 python3 -m json.tool >/dev/null
|
||||
|
||||
- name: Run script unit tests
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install --disable-pip-version-check requests
|
||||
python3 -m unittest discover -s tests -p '*_test.py' -v
|
||||
|
||||
- name: Verify build and packaging contracts
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -33,7 +33,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
appimage:
|
||||
name: AppImage (Ubuntu 26.04)
|
||||
name: AppImage (AnyLinux)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
@@ -49,50 +49,89 @@ jobs:
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
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 \
|
||||
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 zlib1g-dev \
|
||||
libplacebo-dev \
|
||||
bubblewrap xdg-dbus-proxy patchelf jq unzip file curl \
|
||||
ca-certificates default-jdk-headless libgif7 python3 \
|
||||
python3-capstone python3-yaml python3-requests \
|
||||
python3-urllib3 python3-certifi python3-charset-normalizer \
|
||||
python3-idna squashfs-tools
|
||||
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
|
||||
pacman -Syu --noconfirm --needed \
|
||||
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
|
||||
nlohmann-json libyaml libelf minizip minizip-ng capstone gtk4 \
|
||||
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
|
||||
vulkan-headers zlib libplacebo bubblewrap xdg-dbus-proxy \
|
||||
patchelf jq unzip file ca-certificates dbus zsync
|
||||
|
||||
# Reduced ABI-compatible packages belong only in this builder.
|
||||
# The native updater does not require a JRE or Android SDK.
|
||||
./scripts/install_anylinux_dependencies.sh
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
--output /tmp/quick-sharun \
|
||||
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/quick-sharun.sh
|
||||
echo "fa66d0189e1e87df781c049dfb23cbb1e753786056f693a4483d6d3af61e7ee1 /tmp/quick-sharun" | \
|
||||
sha256sum --check --strict
|
||||
chmod 0755 /tmp/quick-sharun
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
--output /tmp/sharun \
|
||||
https://github.com/pkgforge-dev/Anylinux-sharun/releases/download/2.2.6/sharun-x86_64
|
||||
echo "a4d51752f9861b4870ca529fea04087310b28f8943cc55b99a95b777d4db92ec /tmp/sharun" | \
|
||||
sha256sum --check --strict
|
||||
chmod 0755 /tmp/sharun
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
--output /tmp/anylinux.c \
|
||||
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/lib/anylinux.c
|
||||
echo "f50650ad96d177559bdd0737df509fa20bd4af08887631c738fc1f31bfac185f /tmp/anylinux.c" | \
|
||||
sha256sum --check --strict
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
--output /tmp/appimagetool \
|
||||
https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage
|
||||
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | \
|
||||
https://github.com/pkgforge-dev/appimagetool/releases/download/0.3.3/appimagetool-x86_64-linux
|
||||
echo "0d01a4e2628efc897be9663f0faeed6a171e1851270b4e1ce4c22434a36ecebd /tmp/appimagetool" | \
|
||||
sha256sum --check --strict
|
||||
install -m 0755 /tmp/appimagetool /usr/local/bin/appimagetool
|
||||
chmod 0755 /tmp/appimagetool
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
--output /tmp/android-build-tools.zip \
|
||||
https://dl.google.com/android/repository/build-tools_r35_linux.zip
|
||||
echo "bd3a4966912eb8b30ed0d00b0cda6b6543b949d5ffe00bea54c04c81e1561d88 /tmp/android-build-tools.zip" | \
|
||||
--output /tmp/uruntime \
|
||||
https://github.com/VHSgunzo/uruntime/releases/download/v0.5.9/uruntime-appimage-dwarfs-lite-x86_64
|
||||
echo "cef962c299f2fa19b2b3cdf2fa1565ee8541796cc89b9a97a591f94041e8b083 /tmp/uruntime" | \
|
||||
sha256sum --check --strict
|
||||
unzip -q /tmp/android-build-tools.zip -d /tmp/android-build-tools
|
||||
android_tools_root="$(dirname -- "$(find /tmp/android-build-tools -type f -name apksigner -print -quit)")"
|
||||
[[ -n "$android_tools_root" ]]
|
||||
chmod 0755 /tmp/uruntime
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
--output /tmp/mkdwarfs \
|
||||
https://github.com/mhx/dwarfs/releases/download/v0.15.6/dwarfs-universal-0.15.6-Linux-x86_64
|
||||
echo "50891c38ba359db8271819a6cbf6aaa8068681523f0c4f2b8242007a45edaa28 /tmp/mkdwarfs" | \
|
||||
sha256sum --check --strict
|
||||
chmod 0755 /tmp/mkdwarfs
|
||||
|
||||
git config --global --add safe.directory /workspace
|
||||
git config --global --add safe.directory /workspace/third_party/Vulkan-Headers
|
||||
git config --global --add safe.directory /workspace/third_party/libjnivm
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
export MOCKTAIL_ANDROID_BUILD_TOOLS_ROOT="$android_tools_root"
|
||||
# Install into the disposable container /usr before deployment.
|
||||
export MOCKTAIL_ANYLINUX_SYSTEM_INSTALL=1
|
||||
export MOCKTAIL_ANYLINUX_SHARUN=/tmp/sharun
|
||||
export MOCKTAIL_ANYLINUX_C_SOURCE=/tmp/anylinux.c
|
||||
export RUNTIME=/tmp/uruntime
|
||||
export DWARFS_CMD=/tmp/mkdwarfs
|
||||
make appimage BUILD_DIR=build-appimage JOBS=4 \
|
||||
APPIMAGE=dist/Mocktail-x86_64.AppImage \
|
||||
PORTABLE_MODE=standalone
|
||||
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
|
||||
ANYLINUX_PACKAGER=/tmp/quick-sharun \
|
||||
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
|
||||
test -x dist/Mocktail-x86_64.AppImage
|
||||
'
|
||||
|
||||
- name: Smoke-test AppImage without FUSE
|
||||
shell: bash
|
||||
run: |
|
||||
smoke_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$smoke_dir"' EXIT
|
||||
export XDG_DATA_HOME="$smoke_dir/data"
|
||||
export XDG_CONFIG_HOME="$smoke_dir/config"
|
||||
export XDG_CACHE_HOME="$smoke_dir/cache"
|
||||
export XDG_STATE_HOME="$smoke_dir/state"
|
||||
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
|
||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
|
||||
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -182,6 +221,7 @@ jobs:
|
||||
cmake -S . -B build-rpm -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
|
||||
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
|
||||
-DBUILD_TESTING=OFF
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/build/
|
||||
/build_*/
|
||||
/build-flatpak/
|
||||
/build-anylinux*/
|
||||
/.flatpak-builder/
|
||||
/cmake-build-*/
|
||||
/out/
|
||||
@@ -20,6 +21,12 @@ Testing/
|
||||
CMakeUserPresets.json
|
||||
*.cmake.user
|
||||
|
||||
# local direnv development shell cache
|
||||
.direnv/
|
||||
|
||||
# clangd cache
|
||||
.cache
|
||||
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
|
||||
+24
-9
@@ -138,12 +138,7 @@ if(BUILD_TESTING)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
endif()
|
||||
|
||||
add_library(mocktail_crypto_foundation STATIC
|
||||
src/foundation/sha256.cc
|
||||
)
|
||||
add_library(Mocktail::CryptoFoundation ALIAS mocktail_crypto_foundation)
|
||||
target_include_directories(mocktail_crypto_foundation PUBLIC include)
|
||||
mocktail_apply_compile_options(mocktail_crypto_foundation)
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
include(${CMAKE_SOURCE_DIR}/cmake/MocktailPlatformGraphics.cmake)
|
||||
foreach(foundation_target IN ITEMS
|
||||
@@ -201,12 +196,12 @@ target_include_directories(mocktail_bionic_stdio_runtime PUBLIC include)
|
||||
mocktail_apply_compile_options(mocktail_bionic_stdio_runtime)
|
||||
|
||||
find_package(CURL REQUIRED)
|
||||
find_package(OpenSSL REQUIRED)
|
||||
find_package(nlohmann_json CONFIG REQUIRED)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(LIBYAML REQUIRED IMPORTED_TARGET yaml-0.1)
|
||||
pkg_check_modules(MINIZIP REQUIRED IMPORTED_TARGET minizip)
|
||||
pkg_check_modules(CAPSTONE REQUIRED IMPORTED_TARGET capstone)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4)
|
||||
pkg_check_modules(LIBADWAITA REQUIRED IMPORTED_TARGET "libadwaita-1>=1.6")
|
||||
pkg_check_modules(WEBKITGTK6 REQUIRED IMPORTED_TARGET webkitgtk-6.0)
|
||||
@@ -214,6 +209,7 @@ pkg_check_modules(WEBKITGTK6 REQUIRED IMPORTED_TARGET webkitgtk-6.0)
|
||||
add_library(mocktail_update STATIC
|
||||
src/update/android_manifest.cc
|
||||
src/update/apk_bundle.cc
|
||||
src/update/apk_provider.cc
|
||||
src/update/apk_signature.cc
|
||||
src/update/apkpure_provider.cc
|
||||
src/update/candidate_approval.cc
|
||||
@@ -223,6 +219,7 @@ add_library(mocktail_update STATIC
|
||||
src/update/payload_integrity.cc
|
||||
src/update/payload_store.cc
|
||||
src/update/readiness_canary.cc
|
||||
src/update/unsafe_latest_runner.cc
|
||||
src/update/update_config.cc
|
||||
src/update/update_coordinator.cc
|
||||
src/update/zip_archive.cc
|
||||
@@ -231,7 +228,6 @@ add_library(Mocktail::Update ALIAS mocktail_update)
|
||||
target_include_directories(mocktail_update PUBLIC include)
|
||||
target_link_libraries(mocktail_update PUBLIC
|
||||
Mocktail::Compat
|
||||
Mocktail::CryptoFoundation
|
||||
CURL::libcurl
|
||||
OpenSSL::Crypto
|
||||
PkgConfig::LIBYAML
|
||||
@@ -288,6 +284,7 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/roblox_launch_uri.cc
|
||||
src/runtime/roblox_game_session_native_adapter.cc
|
||||
src/runtime/roblox_web_view_bridge.cc
|
||||
src/runtime/roblox_permissions_bridge.cc
|
||||
src/runtime/payload_update_preflight.cc
|
||||
src/runtime/runtime_config.cc
|
||||
src/runtime/runtime_config_bootstrap.cc
|
||||
@@ -298,6 +295,8 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/startup_state_machine.cc
|
||||
src/runtime/supported_launch_policy.cc
|
||||
src/runtime/support_bundle.cc
|
||||
src/runtime/system_proxy.cc
|
||||
src/runtime/texture_memory_policy.cc
|
||||
)
|
||||
add_library(Mocktail::Runtime ALIAS mocktail_runtime)
|
||||
target_include_directories(mocktail_runtime PUBLIC include)
|
||||
@@ -328,9 +327,9 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
|
||||
endif()
|
||||
target_link_libraries(mocktail_runtime PUBLIC Threads::Threads)
|
||||
target_link_libraries(mocktail_runtime PRIVATE
|
||||
Mocktail::CryptoFoundation
|
||||
CURL::libcurl
|
||||
nlohmann_json::nlohmann_json
|
||||
PkgConfig::GIO
|
||||
PkgConfig::LIBYAML
|
||||
${CMAKE_DL_LIBS}
|
||||
)
|
||||
@@ -421,6 +420,7 @@ target_link_libraries(mocktail_linker PUBLIC
|
||||
linker
|
||||
mocktail_compat
|
||||
mocktail_bionic_stdio_runtime
|
||||
mocktail_bionic_abi_exports
|
||||
)
|
||||
mocktail_apply_compile_options(mocktail_linker)
|
||||
target_compile_definitions(mocktail_linker PUBLIC MOCKTAIL_USE_BIONIC_LINKER=1)
|
||||
@@ -449,6 +449,7 @@ add_library(mocktail_window STATIC
|
||||
src/window/roblox_fullscreen_menu_request_gate.cc
|
||||
src/window/video_driver_policy.cc
|
||||
src/window/window.cc
|
||||
src/window/window_creation_policy.cc
|
||||
src/window/window_fullscreen_request_gate.cc
|
||||
src/window/window_game_surface_bridge.cc
|
||||
src/window/window_pointer_capture_owner.cc
|
||||
@@ -461,6 +462,7 @@ target_include_directories(mocktail_window PUBLIC src)
|
||||
mocktail_apply_compile_options(mocktail_window)
|
||||
target_link_libraries(mocktail_window PUBLIC
|
||||
Mocktail::PlatformSdl
|
||||
Mocktail::SdlVulkanWsi
|
||||
SDL3::SDL3
|
||||
${CMAKE_DL_LIBS}
|
||||
PRIVATE Mocktail::Runtime
|
||||
@@ -525,6 +527,19 @@ target_include_directories(mocktail_bionic_abi_exports
|
||||
PRIVATE src
|
||||
)
|
||||
mocktail_apply_compile_options(mocktail_bionic_abi_exports)
|
||||
# These exported Bionic FORTIFY symbols intentionally overlap glibc's
|
||||
# __*_chk entry points. When the bridge is visible to whole-program LTO,
|
||||
# GCC can fold a fortified host call back into its own compatibility wrapper
|
||||
# and turn the resulting tail recursion into an infinite loop. Keep this
|
||||
# translation unit opaque to interprocedural optimization and make its calls
|
||||
# to the host C library explicit external calls.
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
set_property(SOURCE src/compat/bionic_abi_exports.cc APPEND PROPERTY
|
||||
COMPILE_OPTIONS
|
||||
-fno-lto
|
||||
-fno-builtin
|
||||
)
|
||||
endif()
|
||||
target_link_libraries(mocktail_bionic_abi_exports PRIVATE
|
||||
mocktail_bionic_stdio_runtime
|
||||
mocktail_window
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Frequently asked questions
|
||||
|
||||
[How do I change my FPS?](/FAQ.md#how-do-i-change-my-fps)
|
||||
|
||||
### How do I change my FPS?
|
||||
|
||||
|
||||
Go to $HOME/.config/mocktail, there is a file called config.yaml (appears after opening for the first time mocktail)
|
||||
|
||||
|
||||
<img width="460" height="241" alt="image" src="https://github.com/user-attachments/assets/b30b1fea-4937-4858-937a-3096a9e8b2ad" />
|
||||
|
||||
|
||||
with your text editor of preference search for "graphics"
|
||||
|
||||
|
||||
<img width="1022" height="619" alt="image" src="https://github.com/user-attachments/assets/f86791e9-7ac3-4d13-b81b-3cf9e3e64c3d" />
|
||||
|
||||
|
||||
change the display value to your desired fps, also turn off vsync if you want more fps than your display supports
|
||||
@@ -14,6 +14,9 @@ PORTABLE_MODE ?= standalone
|
||||
PORTABLE_CANONICAL_MODE := $(if $(filter dynamic minimal,$(PORTABLE_MODE)),thin,$(if $(filter full static,$(PORTABLE_MODE)),standalone,$(PORTABLE_MODE)))
|
||||
PORTABLE_DIR ?= dist/mocktail-linux-x86_64-glibc-$(PORTABLE_CANONICAL_MODE)
|
||||
APPIMAGE ?= dist/Mocktail-x86_64.AppImage
|
||||
APPIMAGE_FORMAT ?= classic
|
||||
ANYLINUX_PACKAGER ?= quick-sharun
|
||||
ANYLINUX_APPIMAGETOOL ?= appimagetool
|
||||
PREFIX ?= /usr
|
||||
FLATPAK_BUILD_DIR ?= build-flatpak
|
||||
FLATPAK_MANIFEST ?= packaging/flatpak/space.bigrat.mocktail.json
|
||||
@@ -73,13 +76,11 @@ portable-test: release-runtime ## Verify portable packaging and relocation
|
||||
|
||||
standalone: appimage ## Build the standalone x86-64 AppImage
|
||||
|
||||
appimage: release-runtime ## Build AppImage (requires appimagetool)
|
||||
@command -v appimagetool >/dev/null 2>&1 || { \
|
||||
echo "appimagetool is required for make appimage" >&2; \
|
||||
echo "Install it, then rerun make appimage." >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
@./scripts/package_portable.sh --build-dir "$(BUILD_DIR)" \
|
||||
appimage: release-runtime ## Build AppImage (APPIMAGE_FORMAT=classic|anylinux)
|
||||
@MOCKTAIL_APPIMAGE_FORMAT="$(APPIMAGE_FORMAT)" \
|
||||
MOCKTAIL_ANYLINUX_PACKAGER="$(ANYLINUX_PACKAGER)" \
|
||||
MOCKTAIL_ANYLINUX_APPIMAGETOOL="$(ANYLINUX_APPIMAGETOOL)" \
|
||||
./scripts/package_portable.sh --build-dir "$(BUILD_DIR)" \
|
||||
--libc glibc --output "$(PORTABLE_DIR)" --mode "$(PORTABLE_MODE)" \
|
||||
--appimage "$(APPIMAGE)"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[](https://github.com/komaruworld/mocktail/stargazers)
|
||||
[](https://github.com/komaruworld/mocktail/releases/latest)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/rhgpfcmFSD)
|
||||
|
||||
Mocktail runs the Android `x86_64` Roblox client on Linux, including a Linux
|
||||
userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI
|
||||
@@ -112,6 +113,10 @@ Put a JSON object in `$XDG_CONFIG_HOME/mocktail/fflags.json` (usually
|
||||
"DFIntExample": "120"
|
||||
}
|
||||
```
|
||||
## FAQ
|
||||
|
||||
To see the most Frequently Asked Question go check [FAQ.md](FAQ.md)
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
@@ -169,6 +174,12 @@ make build
|
||||
./build/mocktail
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
Mocktail does not currently work with `hardened_malloc`. Using
|
||||
`hardened_malloc` may cause Mocktail to fail to start or crash during runtime.
|
||||
Run Mocktail without `hardened_malloc` enabled.
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE). Third-party components keep their own licenses.
|
||||
|
||||
@@ -24,6 +24,7 @@ set_target_properties(mocktail_audio_core PROPERTIES
|
||||
add_library(Mocktail::AudioCore ALIAS mocktail_audio_core)
|
||||
|
||||
add_library(mocktail_audio_sdl SHARED
|
||||
${MOCKTAIL_AUDIO_ROOT}/src/audio/sdl_audio_capture.cc
|
||||
${MOCKTAIL_AUDIO_ROOT}/src/audio/sdl_audio_sink.cc
|
||||
)
|
||||
target_include_directories(mocktail_audio_sdl PUBLIC
|
||||
@@ -62,6 +63,7 @@ add_library(Mocktail::FmodJavaAudioRuntime ALIAS
|
||||
|
||||
add_library(mocktail_fmod_jni_audio_bridge STATIC
|
||||
${MOCKTAIL_AUDIO_ROOT}/src/audio/fmod_jni_audio_bridge.cc
|
||||
${MOCKTAIL_AUDIO_ROOT}/src/audio/webrtc_jni_audio_bridge.cc
|
||||
)
|
||||
target_include_directories(mocktail_fmod_jni_audio_bridge PUBLIC
|
||||
${MOCKTAIL_AUDIO_ROOT}/include
|
||||
@@ -199,10 +201,4 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
||||
gtest_discover_tests(fmod_jni_audio_bridge_test
|
||||
PROPERTIES ENVIRONMENT "SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
|
||||
add_test(NAME audio_readiness_gate_test
|
||||
COMMAND bash
|
||||
${MOCKTAIL_AUDIO_ROOT}/tests/audio_readiness_gate_test.sh
|
||||
${MOCKTAIL_AUDIO_ROOT}/scripts/audio_readiness_gate.sh
|
||||
)
|
||||
endif()
|
||||
@@ -44,7 +44,7 @@ target_link_libraries(mocktail_compat PUBLIC
|
||||
nlohmann_json::nlohmann_json
|
||||
Threads::Threads
|
||||
)
|
||||
target_link_libraries(mocktail_compat PRIVATE Mocktail::CryptoFoundation)
|
||||
target_link_libraries(mocktail_compat PRIVATE OpenSSL::Crypto)
|
||||
target_compile_features(mocktail_compat PUBLIC cxx_std_17)
|
||||
|
||||
if(COMMAND mocktail_apply_compile_options)
|
||||
|
||||
@@ -13,11 +13,13 @@ get_filename_component(MOCKTAIL_INPUT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.."
|
||||
)
|
||||
|
||||
add_library(mocktail_input_runtime STATIC
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_gamepad_input.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_input_native_adapter.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_input_router.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_native_text_box_info_reader.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_display_state.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_editor.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
|
||||
)
|
||||
@@ -36,12 +38,14 @@ target_link_libraries(mocktail_input_runtime PUBLIC
|
||||
PRIVATE
|
||||
PkgConfig::FONTCONFIG
|
||||
SDL3_ttf::SDL3_ttf
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
|
||||
mocktail_apply_compile_options(mocktail_input_runtime)
|
||||
|
||||
if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
||||
add_executable(roblox_input_router_test
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_test.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_router_test.cc
|
||||
)
|
||||
target_link_libraries(roblox_input_router_test PRIVATE
|
||||
@@ -68,6 +72,15 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_text_display_state_test)
|
||||
|
||||
add_executable(roblox_text_font_resolver_test
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_text_font_resolver_test.cc
|
||||
)
|
||||
target_link_libraries(roblox_text_font_resolver_test PRIVATE
|
||||
Mocktail::InputRuntime
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_text_font_resolver_test)
|
||||
|
||||
add_executable(roblox_input_native_adapter_test
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_native_adapter_test.cc
|
||||
)
|
||||
@@ -92,9 +105,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
||||
gtest_discover_tests(roblox_input_router_test)
|
||||
gtest_discover_tests(roblox_text_editor_test)
|
||||
gtest_discover_tests(roblox_text_display_state_test)
|
||||
gtest_discover_tests(roblox_text_font_resolver_test)
|
||||
gtest_discover_tests(roblox_input_native_adapter_test)
|
||||
gtest_discover_tests(roblox_native_text_box_info_reader_test)
|
||||
add_test(NAME input_readiness_gate_synthetic
|
||||
COMMAND bash ${MOCKTAIL_INPUT_ROOT}/tests/input_readiness_gate_test.sh
|
||||
)
|
||||
endif()
|
||||
@@ -69,6 +69,7 @@ add_library(mocktail_platform_sdl STATIC
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_application_metadata.cc
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_display_refresh_capabilities.cc
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_event_converter.cc
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_gamepad_manager.cc
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_platform_runtime.cc
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_text_clipboard.cc
|
||||
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_window_icon.cc
|
||||
|
||||
@@ -31,17 +31,23 @@ runtime:
|
||||
# active verified payload.
|
||||
# roblox_library: /absolute/path/to/libroblox.so
|
||||
|
||||
appearance:
|
||||
# String (default: roblox): use Roblox's saved account theme. Supported
|
||||
# overrides: dark, light, or system (follow the desktop color scheme).
|
||||
theme: roblox
|
||||
|
||||
graphics:
|
||||
# String (default: direct-vulkan): graphics backend. Use opengl on hardware
|
||||
# without a working Vulkan driver; OpenGL ES 3.0 or newer is required.
|
||||
# Recommended values: direct-vulkan, opengl, system, angle-vulkan.
|
||||
backend: direct-vulkan
|
||||
# String or integer (default: display): requested frame-rate policy.
|
||||
# Supported values: display, unlimited, 30, 60, 120, 144, 240. Unlimited
|
||||
# selects Roblox's unmodified 240-FPS scheduler maximum.
|
||||
frame_rate_limit: display
|
||||
# String (default: auto): presentation synchronization: auto, on, or off.
|
||||
vsync: auto
|
||||
# Optional string or integer: Mocktail frame-rate override. Leave commented
|
||||
# (or use -1) to let Roblox Basic Settings own the cap. Supported overrides:
|
||||
# display, unlimited, or any positive integer; fixed values are forwarded to
|
||||
# DFIntTaskSchedulerTargetFps without a whitelist.
|
||||
# frame_rate_limit: -1
|
||||
# Optional presentation synchronization override: auto, on, or off.
|
||||
# vsync: off
|
||||
|
||||
performance:
|
||||
# Boolean (default: false): size Roblox scheduler/render queues from every
|
||||
@@ -63,6 +69,10 @@ audio:
|
||||
# String (default: default): `default` follows the host system output.
|
||||
# To pin output, copy an exact SDL device name printed during startup.
|
||||
output_device: default
|
||||
# String (default: default): input device name, `id:<number>` printed during
|
||||
# startup, or `disabled`. IDs disambiguate duplicate names but may change
|
||||
# between boots; prefer the exact device name when it is unique.
|
||||
input_device: default
|
||||
|
||||
integrations:
|
||||
discord_rpc:
|
||||
@@ -91,20 +101,29 @@ integrations:
|
||||
# application_id: 123456789012345678
|
||||
|
||||
window:
|
||||
# Integer (default: 1280): initial window width in pixels.
|
||||
# Integer (default: 1280): initial window width in logical desktop units.
|
||||
width: 1280
|
||||
# Integer (default: 720): initial window height in pixels.
|
||||
# Integer (default: 720): initial window height in logical desktop units.
|
||||
height: 720
|
||||
# String (default: Roblox): window title.
|
||||
title: Roblox
|
||||
# Boolean (default: false): render at physical display-pixel density instead
|
||||
# of the logical desktop resolution. Enable only for sharper high-DPI output.
|
||||
high_dpi: false
|
||||
|
||||
# Optional host HTTP proxy. Uncomment both values together. The host must not
|
||||
# contain a scheme such as http://.
|
||||
# network:
|
||||
# # String (optional, default: disabled): proxy host name or IP address.
|
||||
# proxy_host: 127.0.0.1
|
||||
# # Integer (optional, default: disabled): proxy TCP port from 1 to 65535.
|
||||
# proxy_port: 8080
|
||||
network:
|
||||
# Boolean (default: false): follow the host HTTP/SOCKS5 proxy and
|
||||
# automatically use its current port. Do not combine with a fixed proxy.
|
||||
use_system_proxy: false
|
||||
# Optional fixed HTTP proxy. Uncomment both values together. The host must
|
||||
# not contain a scheme such as http://.
|
||||
# String (optional, default: disabled): proxy host name or IP address.
|
||||
# proxy_host: 127.0.0.1
|
||||
# Integer (optional, default: disabled): proxy TCP port from 1 to 65535.
|
||||
# proxy_port: 8080
|
||||
# Absolute path (optional): custom PEM CA bundle. Mocktail reads this file
|
||||
# directly and does not replace it during Roblox payload updates.
|
||||
# ca_bundle: /home/user/.config/mocktail/cacert.pem
|
||||
|
||||
updates:
|
||||
# Boolean (default: true): check the latest Roblox version, derive an exact
|
||||
@@ -112,7 +131,9 @@ updates:
|
||||
# backend, and promote only on success. An existing current payload is
|
||||
# preserved when probation fails.
|
||||
automatic: true
|
||||
# String (default: apk-pure): direct x86_64 APK provider.
|
||||
# String (default: apk-pure): direct x86_64 APK provider selection. Both
|
||||
# accepted values resolve the provider chain, which currently starts with
|
||||
# APKPure. Supported values: auto, apk-pure.
|
||||
source: apk-pure
|
||||
# Reserved for desktop update integrations. `mocktail_updater` itself never
|
||||
# launches another process after changing the active payload.
|
||||
|
||||
@@ -42,6 +42,46 @@
|
||||
"allow_host_abi_bridges": true,
|
||||
"allow_host_constructor_replay": true,
|
||||
"reason": "Verified 2026-07-16 on the current host: independently signature-matched native allocator and pre-JNI state, 3479 constructors replayed without recovery, JNI_OnLoad and ASMA/V2 startup, 2988 Vulkan shaders, a real vkQueuePresentKHR, and explicit lifecycle teardown to Stopped; legacy executable patches and signal recovery remain disabled."
|
||||
},
|
||||
{
|
||||
"version_name": "2.734.917",
|
||||
"version_code": 2908,
|
||||
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
"allow_legacy_binary_patches": false,
|
||||
"allow_host_abi_bridges": true,
|
||||
"allow_host_constructor_replay": true,
|
||||
"user_game_settings_fullscreen_setter_rva": "0x44fee64",
|
||||
"fmod_output_device_bridge": {
|
||||
"vtable_rva": "0x6b7eee8",
|
||||
"string_constructor_rva": "0x1cfd5ec",
|
||||
"count_method_rva": "0x32736d2",
|
||||
"info_method_rva": "0x3273772",
|
||||
"current_method_rva": "0x3273722",
|
||||
"select_method_rva": "0x32734a6"
|
||||
},
|
||||
"reason": "Verified 2026-08-28 on the current host: the machine-derived exact-Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries; fullscreen state synchronization and Build-ID-scoped FmodAudioDevice output interposition were re-derived for the current payload; legacy executable patches and signal recovery remain disabled."
|
||||
},
|
||||
{
|
||||
"version_name": "2.736.1408",
|
||||
"version_code": 2998,
|
||||
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"status": "supported",
|
||||
"default_allowed": true,
|
||||
"allow_legacy_binary_patches": false,
|
||||
"allow_host_abi_bridges": true,
|
||||
"allow_host_constructor_replay": true,
|
||||
"user_game_settings_fullscreen_setter_rva": "0x45ad8aa",
|
||||
"fmod_output_device_bridge": {
|
||||
"vtable_rva": "0x6c58040",
|
||||
"string_constructor_rva": "0x1d32df8",
|
||||
"count_method_rva": "0x32d0ee0",
|
||||
"info_method_rva": "0x32d0f80",
|
||||
"current_method_rva": "0x32d0f30",
|
||||
"select_method_rva": "0x32d0cb4"
|
||||
},
|
||||
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,64 +1,64 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
|
||||
"payload_sha256": "3e9c26c81186f93458ff65d8a8bc240c220974db02b8388b91340b77b336997d",
|
||||
"payload_id": "2628-1686400865ae0e408cd7bd67de7a439625c6fd13",
|
||||
"payload_path": "payloads/2628-1686400865ae0e408cd7bd67de7a439625c6fd13",
|
||||
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"payload_sha256": "4343a6a900d1fca27ff8e10b9d9c86bee40a5d0ba547cb29903b7769f4f42a9d",
|
||||
"payload_id": "2998-ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"payload_path": "payloads/2998-ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"reference": {
|
||||
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
|
||||
"payload_sha256": "92ca0e4d8bc4e1c7a37fc2b52544869299ec8b9a40ea8da96ebac66660e0b1af"
|
||||
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
|
||||
"payload_sha256": "72c3b1a5c151b5ac0bfc5394ce3a91775884fbd777589ccab8ac1c4f9885f739"
|
||||
},
|
||||
"profile": {
|
||||
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
|
||||
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"bridge_entries": [
|
||||
{
|
||||
"rva": "0x1c39ea2",
|
||||
"rva": "0x1d2c1e2",
|
||||
"kind": "allocate",
|
||||
"label": "small-allocate"
|
||||
},
|
||||
{
|
||||
"rva": "0x1fe4c84",
|
||||
"rva": "0x1d2f43a",
|
||||
"kind": "usable_size",
|
||||
"label": "usable-size"
|
||||
},
|
||||
{
|
||||
"rva": "0x203daae",
|
||||
"rva": "0x21e68bf",
|
||||
"kind": "reallocate",
|
||||
"label": "reallocate"
|
||||
},
|
||||
{
|
||||
"rva": "0x1c39c3a",
|
||||
"rva": "0x1d2bf82",
|
||||
"kind": "allocate",
|
||||
"label": "allocate"
|
||||
},
|
||||
{
|
||||
"rva": "0x1c8dbf8",
|
||||
"rva": "0x1d47a63",
|
||||
"kind": "aligned_allocate",
|
||||
"label": "aligned-allocate-direct"
|
||||
},
|
||||
{
|
||||
"rva": "0x1c3eb72",
|
||||
"rva": "0x1d2f9d9",
|
||||
"kind": "free",
|
||||
"label": "free"
|
||||
}
|
||||
],
|
||||
"data_seeds": {
|
||||
"allocator_object_slot": "0x7030ae0",
|
||||
"empty_string_slot": "0x703a7e8",
|
||||
"jni_singleton_slot": "0x72a16d0",
|
||||
"allocator_object_slot": "0x7652e70",
|
||||
"empty_string_slot": "0x775fd28",
|
||||
"jni_singleton_slot": "0x7bb4340",
|
||||
"jni_singleton_bytes": 1024,
|
||||
"arena_initializer": "0x1c39f1d",
|
||||
"allocator_thread_initializer": "0x2040817",
|
||||
"arena_guard_slot": "0x71e92f0",
|
||||
"arena_table_slot": "0x71e9380",
|
||||
"arena_initializer": "0x1d2c5f8",
|
||||
"allocator_thread_initializer": "0x1db7b57",
|
||||
"arena_guard_slot": "0x7af0430",
|
||||
"arena_table_slot": "0x7af04c0",
|
||||
"arena_table_slot_count": 4194304
|
||||
},
|
||||
"native_allocator": {
|
||||
"allocate": "0x1c39c3a",
|
||||
"deallocate": "0x1c3eb72"
|
||||
"allocate": "0x1d2bf82",
|
||||
"deallocate": "0x1d2f9d9"
|
||||
},
|
||||
"init_array_offset": "0x6c2c9b0",
|
||||
"init_array_count": 3481,
|
||||
"init_array_offset": "0x7057818",
|
||||
"init_array_count": 3570,
|
||||
"constructor_run_ranges": [
|
||||
{
|
||||
"begin": 2,
|
||||
@@ -66,32 +66,32 @@
|
||||
},
|
||||
{
|
||||
"begin": 5,
|
||||
"end_exclusive": 3481
|
||||
"end_exclusive": 3570
|
||||
}
|
||||
],
|
||||
"native_mimalloc_constructor_run_ranges": [
|
||||
{
|
||||
"begin": 2,
|
||||
"end_exclusive": 3481
|
||||
"end_exclusive": 3570
|
||||
}
|
||||
],
|
||||
"native_mimalloc_thread_initializer_after_constructor": 2,
|
||||
"native_pre_jni_bootstrap": {
|
||||
"registry_initializer": "0x1fe72e5",
|
||||
"registry_slot": "0x71ed428"
|
||||
"registry_initializer": "0x21e8e09",
|
||||
"registry_slot": "0x7af4598"
|
||||
},
|
||||
"default_allocator_strategy": "native_mimalloc"
|
||||
},
|
||||
"derivation_anchors": {
|
||||
"signature_version": 1,
|
||||
"allocator_object_initializer_rva": "0x1fbf1de",
|
||||
"empty_string_initializer_rva": "0x1fc82b0",
|
||||
"jni_singleton_initializer_rva": "0x1c39a34",
|
||||
"allocator_object_initializer_rva": "0x21bde7d",
|
||||
"empty_string_initializer_rva": "0x21c9a86",
|
||||
"jni_singleton_initializer_rva": "0x1d2aec0",
|
||||
"constructor_rvas": {
|
||||
"2": "0x1c3c764",
|
||||
"3": "0x1c38c00",
|
||||
"4": "0x1c39884",
|
||||
"5": "0x1c39a34"
|
||||
"2": "0x1d2f731",
|
||||
"3": "0x1d2af84",
|
||||
"4": "0x1d2bc08",
|
||||
"5": "0x1d2aec0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"package": "com.roblox.client",
|
||||
"version_name": "2.725.1142",
|
||||
"version_code": 2546,
|
||||
"version_name": "2.736.1408",
|
||||
"version_code": 2998,
|
||||
"abi": "x86_64",
|
||||
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
|
||||
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
"sha256": {
|
||||
"libroblox": "92ca0e4d8bc4e1c7a37fc2b52544869299ec8b9a40ea8da96ebac66660e0b1af",
|
||||
"base_apk": "eba476fec3020eaceb28436c1d160eb35d6689d8ffb060e09678535584babc7a",
|
||||
"x86_64_split_apk": "5c4193c9700481079cee958b38ba9cbb97768b0a130b7f704ed084753f01ce2b"
|
||||
"libroblox": "4343a6a900d1fca27ff8e10b9d9c86bee40a5d0ba547cb29903b7769f4f42a9d",
|
||||
"base_apk": "a4a71ec39df6619a56ab7adce0959d1d60354835485b53806f4026914287ea86",
|
||||
"x86_64_split_apk": "a4a71ec39df6619a56ab7adce0959d1d60354835485b53806f4026914287ea86"
|
||||
},
|
||||
"signing_certificates_sha256": [
|
||||
"2bebd189e8d3106401347056c93d045b61e20e22d0c3cbed85474aeb00a3d12a",
|
||||
"44932ea35a17a267372d71b54d1a0cb3da0dca5113e94406ae2fe18090ba1477"
|
||||
],
|
||||
"assets": {
|
||||
"file_count": 1837
|
||||
"file_count": 1836,
|
||||
"sha256_tree": "caee48e11c194bc11852493f53ff67d5e2bb233172f12b87ea5fca97c0c10840"
|
||||
},
|
||||
"source": "sober-update-service",
|
||||
"imported_at": "2026-07-09T19:12:54Z",
|
||||
"compatibility_status": "supported"
|
||||
"source": "apk-pure-native",
|
||||
"imported_at": "2026-08-31T16:30:19Z",
|
||||
"compatibility_status": "supported",
|
||||
"apk_layout": "monolithic"
|
||||
}
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1787135253,
|
||||
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1785031560,
|
||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
description = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
|
||||
|
||||
inputs = {
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [
|
||||
|
||||
];
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
# "aarch64-linux"
|
||||
# "aarch64-darwin"
|
||||
# "x86_64-darwin"
|
||||
];
|
||||
perSystem =
|
||||
{
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
package = pkgs.callPackage ./packaging/nix/package.nix { };
|
||||
in
|
||||
{
|
||||
packages.default = package;
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = [ package ];
|
||||
|
||||
# nixpkgs installs the header we need under include/libutf8proc
|
||||
NIX_CFLAGS_COMPILE = "-I${pkgs.libutf8proc}/include/libutf8proc";
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cmake
|
||||
git
|
||||
lld
|
||||
ninja
|
||||
pkg-config
|
||||
vulkan-headers
|
||||
|
||||
llvmPackages_22.clang-tools
|
||||
llvmPackages_22.libclang
|
||||
|
||||
# debugging
|
||||
gdb
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
|
||||
|
||||
# nix packages graphics drivers differently, and mocktail will still work if it cannot guarantee symbols
|
||||
export MOCKTAIL_REQUIRE_REAL_GRAPHICS=0
|
||||
'';
|
||||
};
|
||||
};
|
||||
flake = { };
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,18 @@ int mocktail_pthread_mutex_destroy(pthread_mutex_t* mutex);
|
||||
int mocktail_pthread_mutex_lock(pthread_mutex_t* mutex);
|
||||
int mocktail_pthread_mutex_trylock(pthread_mutex_t* mutex);
|
||||
int mocktail_pthread_mutex_unlock(pthread_mutex_t* mutex);
|
||||
int mocktail_pthread_once(pthread_once_t* once_control,
|
||||
void (*init_routine)(void));
|
||||
int mocktail_pthread_spin_init(pthread_spinlock_t* lock, int pshared);
|
||||
int mocktail_pthread_spin_destroy(pthread_spinlock_t* lock);
|
||||
int mocktail_pthread_spin_lock(pthread_spinlock_t* lock);
|
||||
int mocktail_pthread_spin_trylock(pthread_spinlock_t* lock);
|
||||
int mocktail_pthread_spin_unlock(pthread_spinlock_t* lock);
|
||||
int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
|
||||
const pthread_barrierattr_t* attr,
|
||||
unsigned count);
|
||||
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
|
||||
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
|
||||
|
||||
extern void* mocktail_gameactivity_on_start_native;
|
||||
extern void* mocktail_gameactivity_on_resume_native;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
#include <pthread.h>
|
||||
|
||||
// Bionic and glibc pthread_rwlock_t storage is not ABI-compatible. The guest
|
||||
// address is an opaque key; the host object lives entirely in this runtime.
|
||||
// Bionic pthread_rwlock_t is a state word in guest storage. rdlock/wrlock/
|
||||
// unlock use __atomic_* and futex on that word. Zero-initialized storage
|
||||
// still locks.
|
||||
extern "C" {
|
||||
|
||||
int mocktail_bionic_pthread_rwlock_init(pthread_rwlock_t* rwlock,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
#include <semaphore.h>
|
||||
|
||||
// Bionic and glibc use different sem_t representations. These exports keep
|
||||
// host semaphore objects out of guest storage and key them by guest address.
|
||||
// Bionic sem_t is a count word in guest storage (value << 1, shared bit 0).
|
||||
// Wait/post use __atomic_* and futex on that word so inlined guest futex
|
||||
// waiters wake. Process-shared semaphores are rejected.
|
||||
extern "C" {
|
||||
|
||||
int mocktail_bionic_sem_init(sem_t* semaphore, int process_shared,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cwchar>
|
||||
|
||||
@@ -17,12 +18,6 @@ namespace mocktail::compat {
|
||||
constexpr size_t kBionicFileSize = 152;
|
||||
constexpr size_t kBionicStandardStreamCount = 3;
|
||||
|
||||
FILE* TranslateBionicFile(FILE* stream) noexcept;
|
||||
void* BionicFileArraySymbolAddress() noexcept;
|
||||
void* BionicStdinSymbolAddress() noexcept;
|
||||
void* BionicStdoutSymbolAddress() noexcept;
|
||||
void* BionicStderrSymbolAddress() noexcept;
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
extern "C" {
|
||||
@@ -31,8 +26,31 @@ alignas(sizeof(void*)) extern unsigned char
|
||||
__sF[mocktail::compat::kBionicStandardStreamCount]
|
||||
[mocktail::compat::kBionicFileSize];
|
||||
|
||||
} // extern "C"
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
inline FILE* TranslateBionicFile(FILE* stream) noexcept {
|
||||
const uintptr_t address = reinterpret_cast<uintptr_t>(stream);
|
||||
if (__builtin_expect(address == reinterpret_cast<uintptr_t>(&__sF[0]), 0)) {
|
||||
return stdin;
|
||||
}
|
||||
if (__builtin_expect(address == reinterpret_cast<uintptr_t>(&__sF[1]), 0)) {
|
||||
return stdout;
|
||||
}
|
||||
if (__builtin_expect(address == reinterpret_cast<uintptr_t>(&__sF[2]), 0)) {
|
||||
return stderr;
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
void* BionicFileArraySymbolAddress() noexcept;
|
||||
void* BionicStdinSymbolAddress() noexcept;
|
||||
void* BionicStdoutSymbolAddress() noexcept;
|
||||
void* BionicStderrSymbolAddress() noexcept;
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
// FILE values returned by host fopen/fdopen pass through unchanged. Pointers
|
||||
// into Bionic's ABI-sized __sF array are translated to the corresponding host
|
||||
// standard stream at this single boundary.
|
||||
|
||||
@@ -54,6 +54,8 @@ ExternalHostAbiProfileResult InitializeHostAbiProfileFromEnvironment(
|
||||
const HostAbiProfile* FindLoadedExternalHostAbiProfile(
|
||||
std::string_view build_id) noexcept;
|
||||
|
||||
void SetHostAbiProfileHashingFaultForTesting(bool enabled) noexcept;
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_HOST_ABI_PROFILE_LOADER_H_
|
||||
+175
-1
@@ -59,6 +59,8 @@ struct RobloxCredentialView {
|
||||
};
|
||||
|
||||
using RobloxCredentialProvider = RobloxCredentialView (*)(const void *context);
|
||||
using RobloxCookieGetter = jstring (*)(JNIEnv* env, jclass clazz,
|
||||
jstring domain);
|
||||
|
||||
jobject CreateAndroidConfiguration(JNIEnv *env);
|
||||
|
||||
@@ -80,6 +82,67 @@ struct FmodAudioDeviceCallbacks {
|
||||
void (*shutdown)(void *context) = nullptr;
|
||||
};
|
||||
|
||||
// Parameters passed by WebRtcAudioManager's constructor to the guest's
|
||||
// nativeCacheAudioParameters(IIIZZZZZZZIIJ)V. Buffer sizes are PCM frames,
|
||||
// not bytes. Querying them must not open a microphone.
|
||||
struct WebRtcAudioManagerParameters {
|
||||
int sample_rate_hz = 0;
|
||||
int output_channels = 0;
|
||||
int input_channels = 0;
|
||||
bool hardware_aec = false;
|
||||
bool hardware_agc = false;
|
||||
bool hardware_ns = false;
|
||||
bool low_latency_output = false;
|
||||
bool low_latency_input = false;
|
||||
bool pro_audio = false;
|
||||
bool aaudio = false;
|
||||
int output_buffer_size_frames = 0;
|
||||
int input_buffer_size_frames = 0;
|
||||
};
|
||||
|
||||
struct WebRtcAudioManagerCallbacks {
|
||||
bool (*get_parameters)(void* context,
|
||||
WebRtcAudioManagerParameters* parameters) = nullptr;
|
||||
bool (*init)(void* context, const void* identity) = nullptr;
|
||||
void (*dispose)(void* context, const void* identity) = nullptr;
|
||||
void (*set_microphone_mute)(void* context, bool muted) = nullptr;
|
||||
};
|
||||
|
||||
using WebRtcAudioRecordDataCallback = void (*)(void *context,
|
||||
const void *identity,
|
||||
std::size_t size_bytes);
|
||||
|
||||
// Host implementation of org/webrtc/voiceengine/WebRtcAudioRecord. Init
|
||||
// returns the number of PCM frames in the stable direct buffer, or -1.
|
||||
struct WebRtcAudioRecordCallbacks {
|
||||
int (*init)(void *context, const void *identity, int sample_rate_hz,
|
||||
int channels, WebRtcAudioRecordDataCallback data_callback,
|
||||
void *data_context, void **direct_buffer,
|
||||
std::size_t *direct_buffer_capacity) = nullptr;
|
||||
bool (*start)(void *context, const void *identity) = nullptr;
|
||||
bool (*stop)(void *context, const void *identity) = nullptr;
|
||||
void (*close)(void *context, const void *identity) = nullptr;
|
||||
void (*shutdown)(void *context) = nullptr;
|
||||
};
|
||||
|
||||
using WebRtcAudioTrackDataCallback = void (*)(void *context,
|
||||
const void *identity,
|
||||
std::size_t size_bytes);
|
||||
|
||||
// Host implementation of org/webrtc/voiceengine/WebRtcAudioTrack. Init
|
||||
// returns the emulated Android AudioTrack buffer size in bytes, or -1.
|
||||
struct WebRtcAudioTrackCallbacks {
|
||||
int (*init)(void *context, const void *identity, int sample_rate_hz,
|
||||
int channels, double buffer_size_factor,
|
||||
WebRtcAudioTrackDataCallback data_callback, void *data_context,
|
||||
void **direct_buffer,
|
||||
std::size_t *direct_buffer_capacity) = nullptr;
|
||||
int (*buffer_size_frames)(void *context, const void *identity) = nullptr;
|
||||
bool (*start)(void *context, const void *identity) = nullptr;
|
||||
bool (*stop)(void *context, const void *identity) = nullptr;
|
||||
void (*close)(void *context, const void *identity) = nullptr;
|
||||
};
|
||||
|
||||
// Guest JNI threads only publish Android window flags. The host callback must
|
||||
// marshal SDL work onto the main thread.
|
||||
struct AndroidWindowCallbacks {
|
||||
@@ -130,6 +193,11 @@ struct MessageBusRequestHandlerCallbacks {
|
||||
std::string (*run)(void *context, JNIEnv *env, jstring message) = nullptr;
|
||||
};
|
||||
|
||||
struct MessageBusAsyncRequestHandlerCallbacks {
|
||||
void (*run)(void* context, JNIEnv* env, jstring message,
|
||||
jstring response_id) = nullptr;
|
||||
};
|
||||
|
||||
// Dispatch accepts Callback.onItemSet(String) only on a receiver created by
|
||||
// this VM. The context stays alive while the callback runs.
|
||||
struct MemStorageCallbackCallbacks {
|
||||
@@ -194,10 +262,12 @@ public:
|
||||
|
||||
JavaVM *GetJavaVM() { return java_vm_; }
|
||||
|
||||
// Resolves the process pseudo-VM that owns an exact JavaVM pointer. Returns
|
||||
// Resolves the live pseudo-VM that owns an exact JavaVM pointer. Returns
|
||||
// null for foreign VMs and after the owner is destroyed.
|
||||
static VM *FromJavaVM(JavaVM *java_vm);
|
||||
|
||||
// Binds this thread's JNI environment to this VM. Switching owners must
|
||||
// not reuse the previous VM's native function table.
|
||||
JNIEnv *GetJNIEnv();
|
||||
|
||||
std::size_t GetClassCount() const { return class_registry_.size(); }
|
||||
@@ -228,6 +298,9 @@ public:
|
||||
// retained privately and returned instead until the provider is cleared.
|
||||
bool CopyRobloxCredentialFromProvider(std::string *credential) const;
|
||||
|
||||
void SetRobloxCookieGetter(RobloxCookieGetter getter);
|
||||
bool RefreshRobloxCredentialFromEngine(JNIEnv* env);
|
||||
|
||||
void SetRobloxCredentialSink(std::shared_ptr<void> context,
|
||||
const RobloxCredentialSinkCallbacks &callbacks);
|
||||
void ClearRobloxCredentialSink();
|
||||
@@ -250,6 +323,55 @@ public:
|
||||
const std::uint8_t *data, std::size_t size);
|
||||
bool DispatchFmodAudioDeviceClose(const void *identity);
|
||||
|
||||
void SetWebRtcAudioManagerCallbacks(
|
||||
std::shared_ptr<void> context,
|
||||
const WebRtcAudioManagerCallbacks& callbacks);
|
||||
void ClearWebRtcAudioManagerCallbacks();
|
||||
void RegisterWebRtcAudioManagerNative(const char* name, const char* signature,
|
||||
void* function);
|
||||
bool DispatchWebRtcAudioManagerConstruct(jobject manager,
|
||||
jlong native_audio_manager);
|
||||
bool DispatchWebRtcAudioManagerInit(jobject manager);
|
||||
void DispatchWebRtcAudioManagerDispose(jobject manager);
|
||||
void DispatchWebRtcAudioManagerMicrophoneMute(jobject manager, bool muted);
|
||||
|
||||
void
|
||||
SetWebRtcAudioRecordCallbacks(std::shared_ptr<void> context,
|
||||
const WebRtcAudioRecordCallbacks &callbacks);
|
||||
void ClearWebRtcAudioRecordCallbacks();
|
||||
int DispatchWebRtcAudioRecordInit(const void *identity, int sample_rate_hz,
|
||||
int channels, void **direct_buffer,
|
||||
std::size_t *direct_buffer_capacity);
|
||||
bool DispatchWebRtcAudioRecordStart(const void *identity);
|
||||
bool DispatchWebRtcAudioRecordStop(const void *identity);
|
||||
void DispatchWebRtcAudioRecordClose(const void *identity);
|
||||
void DispatchWebRtcAudioRecordData(const void *identity,
|
||||
std::size_t size_bytes);
|
||||
|
||||
// RegisterNatives stores these exact WebRTC callbacks so the host capture
|
||||
// thread can emulate the Java AudioRecord thread.
|
||||
void RegisterWebRtcAudioRecordNative(const char *name, const char *signature,
|
||||
void *function);
|
||||
|
||||
void SetWebRtcAudioTrackCallbacks(std::shared_ptr<void> context,
|
||||
const WebRtcAudioTrackCallbacks &callbacks);
|
||||
void ClearWebRtcAudioTrackCallbacks();
|
||||
int DispatchWebRtcAudioTrackInit(const void *identity, int sample_rate_hz,
|
||||
int channels, double buffer_size_factor,
|
||||
void **direct_buffer,
|
||||
std::size_t *direct_buffer_capacity);
|
||||
int DispatchWebRtcAudioTrackBufferSizeFrames(const void *identity);
|
||||
bool DispatchWebRtcAudioTrackStart(const void *identity);
|
||||
bool DispatchWebRtcAudioTrackStop(const void *identity);
|
||||
void DispatchWebRtcAudioTrackClose(const void *identity);
|
||||
void DispatchWebRtcAudioTrackData(const void *identity,
|
||||
std::size_t size_bytes);
|
||||
|
||||
// RegisterNatives stores the exact WebRTC playout callbacks so the host
|
||||
// thread can emulate AudioTrackJavaThread.
|
||||
void RegisterWebRtcAudioTrackNative(const char *name, const char *signature,
|
||||
void *function);
|
||||
|
||||
void SetAndroidWindowCallbacks(std::shared_ptr<void> context,
|
||||
const AndroidWindowCallbacks &callbacks);
|
||||
void ClearAndroidWindowCallbacks();
|
||||
@@ -278,6 +400,16 @@ public:
|
||||
jstring DispatchMessageBusRequestHandler(jobject handler, JNIEnv *env,
|
||||
jstring message);
|
||||
|
||||
// Exact RequestHandlerAsyncRaw.run(String, String) receiver. Clearing stops
|
||||
// new calls; an in-flight call retains the host context until it returns.
|
||||
jobject CreateMessageBusAsyncRequestHandler(
|
||||
std::shared_ptr<void> context,
|
||||
const MessageBusAsyncRequestHandlerCallbacks& callbacks);
|
||||
void ClearMessageBusAsyncRequestHandler(jobject handler);
|
||||
bool DispatchMessageBusAsyncRequestHandler(jobject handler, JNIEnv* env,
|
||||
jstring message,
|
||||
jstring response_id);
|
||||
|
||||
// Creates the exact com.roblox.engine.jni.memstorage.Callback receiver used
|
||||
// by MemStorage.bind. Clear prevents future dispatch while an in-flight call
|
||||
// retains its shared context until the callback returns.
|
||||
@@ -338,6 +470,9 @@ private:
|
||||
RobloxCredentialProvider roblox_credential_provider_ = nullptr;
|
||||
std::string roblox_credential_override_;
|
||||
|
||||
mutable std::mutex roblox_cookie_getter_mutex_;
|
||||
RobloxCookieGetter roblox_cookie_getter_ = nullptr;
|
||||
|
||||
struct RobloxCredentialSinkBinding {
|
||||
std::shared_ptr<void> context;
|
||||
RobloxCredentialSinkCallbacks callbacks;
|
||||
@@ -352,6 +487,36 @@ private:
|
||||
mutable std::mutex fmod_audio_device_mutex_;
|
||||
FmodAudioDeviceBinding fmod_audio_device_binding_;
|
||||
|
||||
struct WebRtcAudioManagerBinding {
|
||||
std::shared_ptr<void> context;
|
||||
WebRtcAudioManagerCallbacks callbacks;
|
||||
};
|
||||
mutable std::mutex webrtc_audio_manager_mutex_;
|
||||
WebRtcAudioManagerBinding webrtc_audio_manager_binding_;
|
||||
void* webrtc_cache_audio_parameters_native_ = nullptr;
|
||||
|
||||
struct WebRtcAudioRecordBinding {
|
||||
std::shared_ptr<void> context;
|
||||
WebRtcAudioRecordCallbacks callbacks;
|
||||
};
|
||||
mutable std::mutex webrtc_audio_record_mutex_;
|
||||
WebRtcAudioRecordBinding webrtc_audio_record_binding_;
|
||||
|
||||
mutable std::mutex webrtc_audio_record_native_mutex_;
|
||||
void *webrtc_cache_direct_buffer_native_ = nullptr;
|
||||
void *webrtc_data_is_recorded_native_ = nullptr;
|
||||
|
||||
struct WebRtcAudioTrackBinding {
|
||||
std::shared_ptr<void> context;
|
||||
WebRtcAudioTrackCallbacks callbacks;
|
||||
};
|
||||
mutable std::mutex webrtc_audio_track_mutex_;
|
||||
WebRtcAudioTrackBinding webrtc_audio_track_binding_;
|
||||
|
||||
mutable std::mutex webrtc_audio_track_native_mutex_;
|
||||
void *webrtc_track_cache_direct_buffer_native_ = nullptr;
|
||||
void *webrtc_get_playout_data_native_ = nullptr;
|
||||
|
||||
struct AndroidWindowBinding {
|
||||
std::shared_ptr<void> context;
|
||||
AndroidWindowCallbacks callbacks;
|
||||
@@ -379,6 +544,15 @@ private:
|
||||
std::unordered_map<jobject, std::shared_ptr<MessageBusRequestHandlerBinding>>
|
||||
message_bus_request_handler_bindings_;
|
||||
|
||||
struct MessageBusAsyncRequestHandlerBinding {
|
||||
std::shared_ptr<void> context;
|
||||
MessageBusAsyncRequestHandlerCallbacks callbacks;
|
||||
};
|
||||
mutable std::mutex message_bus_async_request_handler_mutex_;
|
||||
std::unordered_map<jobject,
|
||||
std::shared_ptr<MessageBusAsyncRequestHandlerBinding>>
|
||||
message_bus_async_request_handler_bindings_;
|
||||
|
||||
struct MemStorageCallbackBinding {
|
||||
std::shared_ptr<void> context;
|
||||
MemStorageCallbackCallbacks callbacks;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#ifndef MOCKTAIL_LIBC_SHIM_VULKAN_ETC1_SKY_TRANSCODER_H_
|
||||
#define MOCKTAIL_LIBC_SHIM_VULKAN_ETC1_SKY_TRANSCODER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace libc_shim {
|
||||
|
||||
// Converts one opaque ETC1 RGB block to an opaque BC1 RGB block.
|
||||
void ConvertEtc1RgbBlockToBc1(const uint8_t *etc1, uint8_t *bc1);
|
||||
|
||||
bool TranscodeEtc1SkyTextureForVulkan(const char *path,
|
||||
std::vector<unsigned char> *data);
|
||||
|
||||
} // namespace libc_shim
|
||||
|
||||
#endif // MOCKTAIL_LIBC_SHIM_VULKAN_ETC1_SKY_TRANSCODER_H_
|
||||
@@ -0,0 +1,76 @@
|
||||
#ifndef MOCKTAIL_AUDIO_SDL_AUDIO_CAPTURE_H_
|
||||
#define MOCKTAIL_AUDIO_SDL_AUDIO_CAPTURE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/audio/audio_sink.h"
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
struct SdlRecordingDevice {
|
||||
std::uint32_t id = 0;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
// The virtual `default` target is not returned. Device IDs are process-local;
|
||||
// names are the stable configuration choice, while `id:<number>` disambiguates
|
||||
// duplicate names for the current host session.
|
||||
Status ListSdlRecordingDevices(std::vector<SdlRecordingDevice>* devices);
|
||||
|
||||
Status ResolveSdlRecordingDevice(
|
||||
std::string_view requested,
|
||||
const std::vector<SdlRecordingDevice>& available_devices,
|
||||
std::uint32_t* recording_device_id, std::string* resolved_name);
|
||||
|
||||
// Must run after SDL audio initialization and before the first capture opens.
|
||||
Status ConfigureSdlRecordingDevice(
|
||||
std::string_view requested,
|
||||
std::vector<SdlRecordingDevice>* available_devices,
|
||||
std::string* resolved_name);
|
||||
|
||||
Status GetConfiguredSdlRecordingDevice(std::uint32_t* recording_device_id,
|
||||
std::string* resolved_name);
|
||||
|
||||
using AudioCaptureDataCallback = void (*)(void* context,
|
||||
std::size_t size_bytes);
|
||||
|
||||
class AudioCapture {
|
||||
public:
|
||||
virtual ~AudioCapture() = default;
|
||||
|
||||
virtual const PcmSpec& output_spec() const = 0;
|
||||
virtual void* buffer_data() = 0;
|
||||
virtual std::size_t buffer_size_bytes() const = 0;
|
||||
virtual std::size_t frames_per_buffer() const = 0;
|
||||
virtual Status Start() = 0;
|
||||
virtual Status Stop() = 0;
|
||||
virtual void Shutdown() = 0;
|
||||
};
|
||||
|
||||
struct SdlAudioCaptureOptions {
|
||||
PcmSpec output_spec;
|
||||
|
||||
// Zero selects the configured process target.
|
||||
std::uint32_t recording_device_id = 0;
|
||||
std::uint32_t frame_duration_ms = 10;
|
||||
AudioCaptureDataCallback data_callback = nullptr;
|
||||
void* data_context = nullptr;
|
||||
};
|
||||
|
||||
// SDL converts the host input to output_spec and delivers fixed-duration
|
||||
// frames through one stable buffer. The callback runs synchronously on SDL's
|
||||
// recording thread after that buffer has been filled.
|
||||
Status CreateSdlAudioCapture(const SdlAudioCaptureOptions& options,
|
||||
std::unique_ptr<AudioCapture>* capture);
|
||||
|
||||
// Called by the SDL subsystem owner before SDL_QuitSubSystem.
|
||||
Status PrepareSdlAudioCaptureSubsystemShutdown();
|
||||
|
||||
} // namespace mocktail::audio
|
||||
|
||||
#endif // MOCKTAIL_AUDIO_SDL_AUDIO_CAPTURE_H_
|
||||
@@ -48,6 +48,13 @@ struct SdlAudioSinkOptions {
|
||||
// Zero selects the configured process target.
|
||||
std::uint32_t playback_device_id = 0;
|
||||
bool start_paused = true;
|
||||
|
||||
// Optional pull provider for the host device. Each call fills one source
|
||||
// buffer whose address remains valid until the next provider call. SDL
|
||||
// copies as many buffers as the current device request needs.
|
||||
bool (*data_needed_callback)(void *context, const void **data,
|
||||
std::size_t *size_bytes) = nullptr;
|
||||
void *data_needed_context = nullptr;
|
||||
};
|
||||
|
||||
// SDL owns conversion, resampling, and borrowed-buffer consumption.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef MOCKTAIL_AUDIO_WEBRTC_JNI_AUDIO_BRIDGE_H_
|
||||
#define MOCKTAIL_AUDIO_WEBRTC_JNI_AUDIO_BRIDGE_H_
|
||||
|
||||
#include "mocktail/status.h"
|
||||
|
||||
namespace jnivm {
|
||||
class VM;
|
||||
}
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
// Installs WebRtcAudioManager, WebRtcAudioRecord and WebRtcAudioTrack. The
|
||||
// manager only advertises the host PCM contract; capture opens on recording
|
||||
// init, never on manager construction. SDL's audio subsystem must remain alive
|
||||
// until ShutdownWebRtcJniAudioBridge.
|
||||
Status InstallWebRtcJniAudioBridge(jnivm::VM* vm);
|
||||
Status ShutdownWebRtcJniAudioBridge(jnivm::VM* vm);
|
||||
|
||||
} // namespace mocktail::audio
|
||||
|
||||
#endif // MOCKTAIL_AUDIO_WEBRTC_JNI_AUDIO_BRIDGE_H_
|
||||
@@ -19,30 +19,14 @@ inline constexpr AndroidVulkanResult kAndroidVulkanSuccess =
|
||||
static_cast<AndroidVulkanResult>(0);
|
||||
inline constexpr AndroidVulkanResult kAndroidVulkanErrorInitializationFailed =
|
||||
static_cast<AndroidVulkanResult>(-3);
|
||||
inline constexpr std::uint64_t kHostInfiniteWaitDiagnosticSliceNs =
|
||||
1000000000ULL;
|
||||
inline constexpr std::uint64_t kHostImageAcquireWatchdogTimeoutNs =
|
||||
kHostInfiniteWaitDiagnosticSliceNs;
|
||||
|
||||
inline constexpr char kAndroidSurfaceExtension[] =
|
||||
"VK_KHR_android_surface";
|
||||
|
||||
// Roblox treats VK_SUBOPTIMAL_KHR as a hard error, so expose it as success.
|
||||
// VK_SUBOPTIMAL_KHR still returns a usable image. Roblox handles it as a fatal
|
||||
// error, so accept it until the host reports a real out-of-date surface.
|
||||
VkResult NormalizeAndroidSwapchainResult(VkResult result);
|
||||
|
||||
// Bounds only infinite acquire waits so a lost compositor wakeup cannot hang.
|
||||
std::uint64_t BoundHostImageAcquireTimeout(std::uint64_t requested_timeout);
|
||||
bool IsHostImageAcquireWatchdogTimeout(std::uint64_t requested_timeout,
|
||||
VkResult host_result);
|
||||
VkResult NormalizeHostImageAcquireResult(std::uint64_t requested_timeout,
|
||||
VkResult host_result);
|
||||
|
||||
// Infinite guest waits use finite host slices without exposing slice timeouts.
|
||||
std::uint64_t BoundHostSynchronizationWaitTimeout(
|
||||
std::uint64_t requested_timeout);
|
||||
bool ShouldContinueHostSynchronizationWait(std::uint64_t requested_timeout,
|
||||
VkResult host_result);
|
||||
|
||||
// Returned storage owns the translated extension names.
|
||||
Status TranslateAndroidVulkanInstanceExtensions(
|
||||
const std::vector<std::string>& android_extensions,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
|
||||
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
@@ -17,10 +18,22 @@ enum class PresentModePolicy {
|
||||
|
||||
PresentModePolicy ResolvePresentModePolicy(std::string_view vsync_value,
|
||||
std::string_view frame_rate_value);
|
||||
|
||||
// Reads MOCKTAIL_VSYNC / MOCKTAIL_FRAME_RATE_LIMIT once. Window pacing and
|
||||
// the Vulkan adapter must use this instead of getenv'ing the same keys.
|
||||
PresentModePolicy CachedPresentModePolicy();
|
||||
|
||||
std::vector<VkPresentModeKHR>
|
||||
FilterPresentModes(PresentModePolicy policy,
|
||||
const std::vector<VkPresentModeKHR> &host_modes);
|
||||
const char *PresentModePolicyName(PresentModePolicy policy);
|
||||
const char *PresentModeKhrName(VkPresentModeKHR mode);
|
||||
|
||||
// Extra images so acquire does not wait on the compositor-held buffer.
|
||||
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
|
||||
std::uint32_t requested,
|
||||
std::uint32_t min_images,
|
||||
std::uint32_t max_images);
|
||||
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_
|
||||
#define MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
@@ -26,7 +27,7 @@ struct WindowOptions {
|
||||
int height = 720;
|
||||
std::string title = "Roblox";
|
||||
WindowSurfaceApi surface_api = WindowSurfaceApi::kAngleEgl;
|
||||
bool high_pixel_density = true;
|
||||
bool high_pixel_density = false;
|
||||
bool initially_hidden = true;
|
||||
|
||||
// Production callers should keep this true. Tests using SDL's dummy driver
|
||||
@@ -58,11 +59,69 @@ struct NativeWindowDescriptor {
|
||||
|
||||
struct QuitEvent {};
|
||||
|
||||
// Android exposes pointer and TextBox geometry in density-independent surface
|
||||
// coordinates. SDL exposes pointer positions in logical window coordinates,
|
||||
// while Vulkan presents to the pixel-sized drawable. Keep all three spaces in
|
||||
// one value so input, IME placement and same-surface overlays use exact inverse
|
||||
// transforms.
|
||||
struct SurfaceCoordinateTransform {
|
||||
int32_t logical_width = 0;
|
||||
int32_t logical_height = 0;
|
||||
int32_t pixel_width = 0;
|
||||
int32_t pixel_height = 0;
|
||||
float density_scale = 1.0F;
|
||||
|
||||
bool valid() const {
|
||||
return logical_width > 0 && logical_height > 0 && pixel_width > 0 &&
|
||||
pixel_height > 0 && std::isfinite(density_scale) &&
|
||||
density_scale > 0.0F;
|
||||
}
|
||||
|
||||
float guest_width() const {
|
||||
return valid() ? static_cast<float>(pixel_width) / density_scale : 0.0F;
|
||||
}
|
||||
float guest_height() const {
|
||||
return valid() ? static_cast<float>(pixel_height) / density_scale : 0.0F;
|
||||
}
|
||||
int32_t rounded_guest_width() const {
|
||||
return valid() ? static_cast<int32_t>(std::lround(guest_width())) : 0;
|
||||
}
|
||||
int32_t rounded_guest_height() const {
|
||||
return valid() ? static_cast<int32_t>(std::lround(guest_height())) : 0;
|
||||
}
|
||||
|
||||
float HostLogicalToGuestX(float coordinate) const {
|
||||
return valid() ? coordinate * static_cast<float>(pixel_width) /
|
||||
static_cast<float>(logical_width) / density_scale
|
||||
: coordinate;
|
||||
}
|
||||
float HostLogicalToGuestY(float coordinate) const {
|
||||
return valid() ? coordinate * static_cast<float>(pixel_height) /
|
||||
static_cast<float>(logical_height) / density_scale
|
||||
: coordinate;
|
||||
}
|
||||
float GuestToHostLogicalX(float coordinate) const {
|
||||
return valid() ? coordinate * density_scale *
|
||||
static_cast<float>(logical_width) /
|
||||
static_cast<float>(pixel_width)
|
||||
: coordinate;
|
||||
}
|
||||
float GuestToHostLogicalY(float coordinate) const {
|
||||
return valid() ? coordinate * density_scale *
|
||||
static_cast<float>(logical_height) /
|
||||
static_cast<float>(pixel_height)
|
||||
: coordinate;
|
||||
}
|
||||
};
|
||||
|
||||
struct WindowResizedEvent {
|
||||
int logical_width = 0;
|
||||
int logical_height = 0;
|
||||
int pixel_width = 0;
|
||||
int pixel_height = 0;
|
||||
// Android DisplayMetrics.density equivalent. This is independent of the
|
||||
// logical-to-pixel drawable ratio on X11/XWayland.
|
||||
float density_scale = 1.0F;
|
||||
};
|
||||
|
||||
struct WindowFocusEvent {
|
||||
@@ -128,9 +187,27 @@ struct TouchEvent {
|
||||
float pressure = 0.0f;
|
||||
};
|
||||
|
||||
enum class GamepadFamily {
|
||||
kUnknown,
|
||||
kXbox,
|
||||
kPlayStation4,
|
||||
kPlayStation5,
|
||||
};
|
||||
|
||||
struct GamepadDescriptor {
|
||||
GamepadFamily family = GamepadFamily::kUnknown;
|
||||
std::string name;
|
||||
// Bit positions are SDL's standardized gamepad buttons and axes, not raw
|
||||
// joystick indices or Android key codes.
|
||||
std::uint32_t buttons = 0;
|
||||
std::uint32_t axes = 0;
|
||||
};
|
||||
|
||||
struct GamepadConnectionEvent {
|
||||
std::int64_t instance_id = 0;
|
||||
bool connected = false;
|
||||
GamepadDescriptor descriptor{};
|
||||
bool remapped = false;
|
||||
};
|
||||
|
||||
struct GamepadAxisEvent {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#ifndef MOCKTAIL_PLATFORM_SDL_GAMEPAD_MANAGER_H_
|
||||
#define MOCKTAIL_PLATFORM_SDL_GAMEPAD_MANAGER_H_
|
||||
|
||||
#include <SDL3/SDL_gamepad.h>
|
||||
|
||||
#include <map>
|
||||
|
||||
#include "mocktail/platform/platform_runtime.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace platform {
|
||||
|
||||
// Main-thread owner. Consumes events from the existing SDL pump; it never
|
||||
// creates another event loop or consumes keyboard/window events.
|
||||
class SdlGamepadManager final {
|
||||
public:
|
||||
using EventFn = void (*)(void*, const PlatformEvent&);
|
||||
|
||||
~SdlGamepadManager();
|
||||
SdlGamepadManager() = default;
|
||||
SdlGamepadManager(const SdlGamepadManager&) = delete;
|
||||
SdlGamepadManager& operator=(const SdlGamepadManager&) = delete;
|
||||
|
||||
Status Initialize(void* context, EventFn emit);
|
||||
void Shutdown();
|
||||
bool HandleEvent(const PlatformEvent& event);
|
||||
void ResendState();
|
||||
|
||||
private:
|
||||
SDL_Gamepad* Open(SDL_JoystickID id);
|
||||
void Remove(SDL_JoystickID id);
|
||||
void Describe(SDL_JoystickID id, SDL_Gamepad* gamepad, bool remapped);
|
||||
void EmitState(SDL_JoystickID id, SDL_Gamepad* gamepad);
|
||||
void Emit(PlatformEventPayload payload);
|
||||
|
||||
std::map<SDL_JoystickID, SDL_Gamepad*> gamepads_;
|
||||
void* context_ = nullptr;
|
||||
EventFn emit_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
} // namespace platform
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_PLATFORM_SDL_GAMEPAD_MANAGER_H_
|
||||
@@ -1,45 +0,0 @@
|
||||
#ifndef MOCKTAIL_MOCKTAIL_SHA256_H_
|
||||
#define MOCKTAIL_MOCKTAIL_SHA256_H_
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail {
|
||||
namespace foundation {
|
||||
|
||||
class Sha256 final {
|
||||
public:
|
||||
Sha256() = default;
|
||||
~Sha256();
|
||||
|
||||
Sha256(const Sha256&) = delete;
|
||||
Sha256& operator=(const Sha256&) = delete;
|
||||
|
||||
void Update(const unsigned char* bytes, std::size_t size);
|
||||
void Update(std::string_view bytes);
|
||||
std::string FinalHex();
|
||||
|
||||
private:
|
||||
void Transform(const unsigned char* block);
|
||||
|
||||
std::array<std::uint32_t, 8> state_ = {
|
||||
0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU,
|
||||
0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U,
|
||||
};
|
||||
std::array<unsigned char, 64> block_{};
|
||||
std::size_t block_bytes_ = 0;
|
||||
std::uint64_t total_bytes_ = 0;
|
||||
bool finalized_ = false;
|
||||
};
|
||||
|
||||
// Returns the lowercase SHA-256 digest of the exact byte sequence, including
|
||||
// embedded NUL bytes.
|
||||
std::string ComputeSha256Hex(std::string_view bytes);
|
||||
|
||||
} // namespace foundation
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_MOCKTAIL_SHA256_H_
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_AUTH_RUNTIME_COMPOSITION_H_
|
||||
#define MOCKTAIL_RUNTIME_AUTH_RUNTIME_COMPOSITION_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -98,6 +99,10 @@ AuthRuntimeComposition ComposeAuthRuntime(
|
||||
services::AuthService& auth_service,
|
||||
std::shared_ptr<services::HttpClient> live_auth_http_client);
|
||||
|
||||
// Persists a raw or prefixed Roblox credential to the given cookie file atomically.
|
||||
bool PersistRobloxCookie(const std::filesystem::path& path,
|
||||
std::string_view cookie_value);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ struct CommandLineOptions {
|
||||
std::string launch_request_json;
|
||||
int launch_argument_index = -1;
|
||||
bool allow_unverified_build = false;
|
||||
// Requests one explicit launch of the provider latest without promoting it
|
||||
// into the managed payload state.
|
||||
bool force_run_latest = false;
|
||||
};
|
||||
|
||||
struct CommandLineParseResult {
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace runtime {
|
||||
inline constexpr int kMaximumSupportedRobloxSchedulerFps = 240;
|
||||
|
||||
enum class FrameRateLimitMode {
|
||||
kUnmanaged,
|
||||
kDisplay,
|
||||
kFixed,
|
||||
kUnlimited,
|
||||
@@ -17,7 +18,7 @@ enum class FrameRateLimitMode {
|
||||
};
|
||||
|
||||
struct FrameRatePolicy {
|
||||
FrameRateLimitMode mode = FrameRateLimitMode::kDisplay;
|
||||
FrameRateLimitMode mode = FrameRateLimitMode::kUnmanaged;
|
||||
int fixed_fps = 0;
|
||||
|
||||
bool valid() const { return mode != FrameRateLimitMode::kInvalid; }
|
||||
@@ -25,7 +26,11 @@ struct FrameRatePolicy {
|
||||
|
||||
FrameRatePolicy ParseFrameRatePolicy(std::string_view value);
|
||||
|
||||
// Merges the scheduler policy into Roblox's supported client-settings ingress.
|
||||
// Enables Roblox's Basic Settings frame-rate control and, when explicitly
|
||||
// requested, merges a Mocktail scheduler policy into client settings.
|
||||
// Unmanaged (-1) does not add a scheduler target, so Roblox owns the cap.
|
||||
// Every positive fixed value is forwarded verbatim to
|
||||
// DFIntTaskSchedulerTargetFps; it is not restricted to common refresh rates.
|
||||
// Unlimited selects the unmodified payload's maximum scheduler target (240).
|
||||
// With VSync auto/off, graphics policy separately requests an unthrottled
|
||||
// Vulkan present mode when the host exposes one.
|
||||
|
||||
@@ -41,6 +41,9 @@ struct GameSurface {
|
||||
uintptr_t native_window = 0;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
// Host content scale for Android PlatformParams.dpiScale. Zero means the
|
||||
// producer has no scale information and the JNI configuration is used.
|
||||
float dpi_scale = 0.0f;
|
||||
};
|
||||
|
||||
enum class GamePresentBackend {
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_GRAPHICS_LAUNCH_POLICY_H_
|
||||
#define MOCKTAIL_RUNTIME_GRAPHICS_LAUNCH_POLICY_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/runtime_config.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Readable ICD manifest for `vendor` that this build can actually load, or an
|
||||
// empty path. Directories are searched in order; a manifest built for another
|
||||
// architecture is never selected.
|
||||
std::string SelectVulkanIcdManifest(
|
||||
const std::vector<std::filesystem::path>& directories,
|
||||
std::string_view vendor);
|
||||
|
||||
// Publishes the resolved graphics backend before the managed payload updater
|
||||
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
|
||||
// through ANGLE/Vulkan or accepts a window without a real graphics context.
|
||||
|
||||
@@ -12,12 +12,16 @@ namespace runtime {
|
||||
struct PayloadUpdatePreflightResult {
|
||||
bool attempted = false;
|
||||
std::string error;
|
||||
// Last failure the updater reported on its own stderr. Empty when the
|
||||
// updater never ran or never explained itself.
|
||||
std::string details;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
PayloadUpdatePreflightResult RunPayloadUpdatePreflight(
|
||||
const Environment& environment, const RuntimePaths& paths);
|
||||
const Environment& environment, const RuntimePaths& paths,
|
||||
bool force_run_latest = false);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
@@ -62,6 +62,14 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
|
||||
// answers microphone authorization from capture configuration. No permission
|
||||
// query opens a recording device.
|
||||
bool MergeAudioCaptureClientSettingsOverrides(bool microphone_enabled,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_PLATFORM_CACHE_MIGRATION_H_
|
||||
#define MOCKTAIL_RUNTIME_PLATFORM_CACHE_MIGRATION_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -37,6 +39,25 @@ PlatformCacheMigrationResult MigratePlatformProfileCaches(
|
||||
const Environment& environment, const RuntimePaths& paths,
|
||||
std::string_view desired_revision);
|
||||
|
||||
struct RobloxThemeCacheResult {
|
||||
std::optional<bool> dark_theme;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// Reads the current account's Roblox theme without changing appStorage. A
|
||||
// missing value leaves dark_theme empty so the caller can use Roblox's own
|
||||
// startup default.
|
||||
RobloxThemeCacheResult ReadRobloxThemeCache(
|
||||
const std::filesystem::path& app_storage_file,
|
||||
std::int64_t authenticated_user_id);
|
||||
|
||||
// Synchronizes only Roblox's local theme keys with the resolved host theme.
|
||||
bool ApplyRobloxThemeCacheOverride(
|
||||
const std::filesystem::path& app_storage_file,
|
||||
std::int64_t authenticated_user_id, bool dark_theme, std::string* error);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
|
||||
@@ -20,14 +20,15 @@ struct DesktopAppPolicyResult {
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// Normalizes only Roblox UniversalApp layout coordinates for a PC profile.
|
||||
// Account eligibility and feature-entitlement fields remain server-owned.
|
||||
// Normalizes Roblox UniversalApp layout coordinates for a PC profile and
|
||||
// applies a fixed theme only when configured. Account eligibility and
|
||||
// feature-entitlement fields remain server-owned.
|
||||
// The guest and authenticated cache keys are both populated so user-switch
|
||||
// startup cannot fall back to the Android Unknown/mobile layout.
|
||||
DesktopAppPolicyResult ApplyDesktopAppPolicy(
|
||||
const std::filesystem::path& app_storage_file,
|
||||
const std::filesystem::path& default_policy_file,
|
||||
std::int64_t authenticated_user_id);
|
||||
std::int64_t authenticated_user_id, std::string_view theme_mode);
|
||||
|
||||
// Routes the normalized policy through Roblox's supported runtime override so
|
||||
// a later GUAC response cannot replace the in-memory desktop layout with the
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/owned_pthread.h"
|
||||
@@ -16,6 +17,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_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
#include "runtime/webview_roblox_cookie.h"
|
||||
|
||||
@@ -55,6 +57,10 @@ struct RobloxExperienceJniFactory {
|
||||
void (*on_sync_cookies)(void*, JNIEnv*, jstring),
|
||||
void (*on_set_cookie)(void*, JNIEnv*, jstring, jstring)) = nullptr;
|
||||
void (*clear_platform_web_callbacks)(void* context) = nullptr;
|
||||
jobject (*create_async_request_handler)(
|
||||
void* context, std::shared_ptr<void> callback_context,
|
||||
void (*run)(void*, JNIEnv*, jstring, jstring)) = nullptr;
|
||||
void (*clear_async_request_handler)(void* context, jobject handler) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return context != nullptr && create_raw_callback != nullptr &&
|
||||
@@ -63,7 +69,9 @@ struct RobloxExperienceJniFactory {
|
||||
create_mem_storage_callback != nullptr &&
|
||||
clear_mem_storage_callback != nullptr &&
|
||||
set_platform_web_callbacks != nullptr &&
|
||||
clear_platform_web_callbacks != nullptr;
|
||||
clear_platform_web_callbacks != nullptr &&
|
||||
create_async_request_handler != nullptr &&
|
||||
clear_async_request_handler != nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,6 +111,7 @@ class RobloxExperienceComposition final {
|
||||
RobloxExperienceMessageBusSymbols message_bus_symbols,
|
||||
RobloxWebViewMessageBusSymbols web_view_symbols,
|
||||
RobloxBrowserServiceSymbols browser_service_symbols,
|
||||
RobloxPermissionsMessageBusSymbols permissions_symbols,
|
||||
RobloxGameSessionSymbols game_symbols,
|
||||
RobloxExperienceJniFactory jni_factory,
|
||||
RobloxFreshLaunchPresentBoundary present_boundary,
|
||||
@@ -110,7 +119,8 @@ class RobloxExperienceComposition final {
|
||||
const SecureRobloxCredential* initial_web_view_credential = nullptr,
|
||||
RobloxExperienceSurfaceProvider surface_provider = {},
|
||||
RobloxExperiencePresenceObserver presence_observer = {},
|
||||
bool clear_persisted_web_view_cookie = false);
|
||||
bool clear_persisted_web_view_cookie = false,
|
||||
bool microphone_enabled = false);
|
||||
~RobloxExperienceComposition();
|
||||
|
||||
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
|
||||
@@ -184,6 +194,7 @@ class RobloxExperienceComposition final {
|
||||
const std::shared_ptr<WebViewHelperProcess>& source_process,
|
||||
uint64_t process_generation, uint64_t logical_generation,
|
||||
const WebViewHelperEvent& event);
|
||||
Status AcceptWebViewRobloxCookie(std::string_view value);
|
||||
Status OpenWebSurface(const std::string& url, const char* transport,
|
||||
WebSurfaceRoute route,
|
||||
WebViewHelperExitObserver exit_observer,
|
||||
@@ -217,6 +228,8 @@ class RobloxExperienceComposition final {
|
||||
const RobloxExperienceMessageBusSymbols message_bus_symbols_;
|
||||
const RobloxWebViewMessageBusSymbols web_view_symbols_;
|
||||
const RobloxBrowserServiceSymbols browser_service_symbols_;
|
||||
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
|
||||
const bool microphone_enabled_;
|
||||
const RobloxGameSessionSymbols game_symbols_;
|
||||
const RobloxExperienceJniFactory jni_factory_;
|
||||
const RobloxFreshLaunchPresentBoundary present_boundary_;
|
||||
@@ -234,6 +247,7 @@ class RobloxExperienceComposition final {
|
||||
std::unique_ptr<RobloxExperienceLaunchBridge> bridge_;
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
|
||||
std::shared_ptr<LifecycleTarget> lifecycle_target_;
|
||||
|
||||
@@ -192,7 +192,8 @@ class RobloxGameSessionNativeAdapter final {
|
||||
Status RebindAndInvoke(const GameSurface& surface, bool resume);
|
||||
Status CreateSurfaceObjectLocked(JNIEnv* env, const GameSurface& surface,
|
||||
jobject* result) const;
|
||||
Status CreatePlatformParamsLocked(JNIEnv* env, jobject* result) const;
|
||||
Status CreatePlatformParamsLocked(JNIEnv* env, const GameSurface& surface,
|
||||
jobject* result) const;
|
||||
Status CheckJniException(JNIEnv* env, const char* operation) const;
|
||||
void ReleaseGlobalReferenceLocked(JNIEnv* env, jobject reference);
|
||||
void ReleaseGlobalReferencesLocked(JNIEnv* env);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -32,6 +33,7 @@ class RobloxInputNativeAdapter final {
|
||||
Status Initialize();
|
||||
Status Release();
|
||||
bool initialized() const;
|
||||
bool SupportsGamepads() const;
|
||||
Status GetMainWindowIsMouseLockedCenter(bool* locked_center);
|
||||
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
|
||||
RobloxInputSink Sink();
|
||||
@@ -48,6 +50,19 @@ class RobloxInputNativeAdapter final {
|
||||
int32_t height);
|
||||
static Status KeyCallback(void* context, bool pressed, int32_t scan_code,
|
||||
int32_t key_code, bool repeat);
|
||||
static Status GamepadSupportedKeyCallback(void* context, int32_t device_id,
|
||||
int32_t key, bool supported,
|
||||
int32_t type);
|
||||
static Status GamepadSupportedMotionCallback(void* context, int32_t device_id,
|
||||
int32_t axis, int32_t direction,
|
||||
bool supported, int32_t type);
|
||||
static Status GamepadConnectCallback(void* context, int32_t device_id,
|
||||
int32_t type);
|
||||
static Status GamepadDisconnectCallback(void* context, int32_t device_id);
|
||||
static Status GamepadButtonCallback(void* context, int32_t device_id,
|
||||
int32_t key, bool pressed);
|
||||
static Status GamepadAxisCallback(void* context, int32_t device_id,
|
||||
int32_t axis, float x, float y, float z);
|
||||
static Status SyncTextCallback(void* context, const char* utf8,
|
||||
std::size_t size, int32_t cursor_utf16);
|
||||
static Status PassTextCallback(void* context, int64_t textbox_handle,
|
||||
@@ -69,10 +84,12 @@ class RobloxInputNativeAdapter final {
|
||||
Status ReleaseFocus(int64_t textbox_handle);
|
||||
Status CheckJniException(JNIEnv* env, const char* operation) const;
|
||||
Status RequireReadyLocked(JNIEnv** env) const;
|
||||
Status AcquireReadyEnv(JNIEnv** env) const;
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxInputSymbols symbols_;
|
||||
mutable std::mutex mutex_;
|
||||
std::atomic<bool> ready_{false};
|
||||
jclass native_input_class_ = nullptr;
|
||||
jclass native_gl_class_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
@@ -105,6 +122,7 @@ class RobloxInputRuntime final {
|
||||
RobloxInputDispatchResult HandleEvent(const platform::PlatformEvent& event);
|
||||
RobloxInputSnapshot Snapshot() const;
|
||||
Status GetMainWindowIsMouseLockedCenter(bool* locked_center);
|
||||
bool SupportsGamepads() const;
|
||||
|
||||
static void PlatformEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
@@ -121,6 +139,7 @@ class RobloxInputRuntime final {
|
||||
bool logged_text_ = false;
|
||||
bool logged_text_unsupported_ = false;
|
||||
bool logged_native_error_ = false;
|
||||
bool logged_gamepad_ = false;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
@@ -17,10 +19,12 @@ struct RobloxInputViewport {
|
||||
int32_t logical_height = 0;
|
||||
int32_t pixel_width = 0;
|
||||
int32_t pixel_height = 0;
|
||||
float density_scale = 1.0F;
|
||||
|
||||
bool valid() const {
|
||||
return logical_width > 0 && logical_height > 0 && pixel_width > 0 &&
|
||||
pixel_height > 0;
|
||||
pixel_height > 0 && std::isfinite(density_scale) &&
|
||||
density_scale > 0.0F;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,12 +38,39 @@ struct AndroidKeyMapping {
|
||||
// Maps SDL's USB scancode to the Linux evdev scan code and Android KeyEvent
|
||||
// keycode observed by the APK. Unmapped keys are rejected rather than passed
|
||||
// through with an ABI-incompatible numeric value.
|
||||
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode);
|
||||
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);
|
||||
|
||||
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
|
||||
int32_t RobloxGamepadType(platform::GamepadFamily family);
|
||||
|
||||
struct RobloxGamepadSink {
|
||||
using SupportedKeyFn = Status (*)(void*, int32_t, int32_t, bool, int32_t);
|
||||
using SupportedMotionFn = Status (*)(void*, int32_t, int32_t, int32_t, bool,
|
||||
int32_t);
|
||||
using ConnectFn = Status (*)(void*, int32_t, int32_t);
|
||||
using DisconnectFn = Status (*)(void*, int32_t);
|
||||
using ButtonFn = Status (*)(void*, int32_t, int32_t, bool);
|
||||
using AxisFn = Status (*)(void*, int32_t, int32_t, float, float, float);
|
||||
|
||||
void* context = nullptr;
|
||||
SupportedKeyFn supported_key = nullptr;
|
||||
SupportedMotionFn supported_motion = nullptr;
|
||||
ConnectFn connect = nullptr;
|
||||
DisconnectFn disconnect = nullptr;
|
||||
ButtonFn button = nullptr;
|
||||
AxisFn axis = nullptr;
|
||||
|
||||
bool valid() const {
|
||||
return supported_key != nullptr && supported_motion != nullptr &&
|
||||
connect != nullptr && disconnect != nullptr && button != nullptr &&
|
||||
axis != nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxInputSink {
|
||||
using MouseMoveFn = Status (*)(void* context, float x, float y, float delta_x,
|
||||
float delta_y);
|
||||
@@ -60,6 +91,7 @@ struct RobloxInputSink {
|
||||
TouchFn touch = nullptr;
|
||||
KeyFn key = nullptr;
|
||||
RobloxTextSink text;
|
||||
RobloxGamepadSink gamepad{};
|
||||
};
|
||||
|
||||
enum class RobloxInputEventKind {
|
||||
@@ -72,6 +104,9 @@ enum class RobloxInputEventKind {
|
||||
kText,
|
||||
kFocus,
|
||||
kViewport,
|
||||
kGamepadConnection,
|
||||
kGamepadButton,
|
||||
kGamepadAxis,
|
||||
};
|
||||
|
||||
enum class RobloxInputDispatchState {
|
||||
@@ -107,6 +142,9 @@ struct RobloxInputSnapshot {
|
||||
uint32_t active_mouse_buttons = 0;
|
||||
uint32_t active_touches = 0;
|
||||
uint32_t active_keys = 0;
|
||||
uint64_t gamepad_events = 0;
|
||||
uint32_t detected_gamepads = 0;
|
||||
uint32_t connected_gamepads = 0;
|
||||
};
|
||||
|
||||
// Stateful translation boundary from SDL platform events to the exact
|
||||
@@ -146,6 +184,16 @@ class RobloxInputRouter final {
|
||||
AndroidKeyMapping android;
|
||||
};
|
||||
|
||||
struct ActiveGamepad {
|
||||
int64_t instance_id = 0;
|
||||
int32_t device_id = 0;
|
||||
platform::GamepadDescriptor descriptor;
|
||||
bool connected = false;
|
||||
uint32_t pressed_buttons = 0;
|
||||
std::array<int16_t, 6> raw_axes{};
|
||||
std::array<float, 6> sent_axes{};
|
||||
};
|
||||
|
||||
RobloxInputDispatchResult HandleMouseMotionLocked(
|
||||
const platform::MouseMotionEvent& event);
|
||||
RobloxInputDispatchResult HandleMouseButtonLocked(
|
||||
@@ -155,6 +203,20 @@ class RobloxInputRouter final {
|
||||
RobloxInputDispatchResult HandleTouchLocked(
|
||||
const platform::TouchEvent& event);
|
||||
RobloxInputDispatchResult HandleKeyLocked(const platform::KeyEvent& event);
|
||||
RobloxInputDispatchResult HandleGamepadConnectionLocked(
|
||||
const platform::GamepadConnectionEvent& event);
|
||||
RobloxInputDispatchResult HandleGamepadButtonLocked(
|
||||
const platform::GamepadButtonEvent& event);
|
||||
RobloxInputDispatchResult HandleGamepadAxisLocked(
|
||||
const platform::GamepadAxisEvent& event);
|
||||
std::vector<ActiveGamepad>::iterator FindGamepadLocked(int64_t instance_id);
|
||||
Status ConfigureGamepadLocked(const ActiveGamepad& gamepad, bool available);
|
||||
Status ConnectGamepadLocked(ActiveGamepad& gamepad);
|
||||
Status DispatchGamepadAxesLocked(ActiveGamepad& gamepad, uint8_t axis,
|
||||
bool* dispatched);
|
||||
Status ReleaseGamepadInputsLocked(ActiveGamepad& gamepad);
|
||||
Status DisconnectGamepadLocked(ActiveGamepad& gamepad);
|
||||
Status DisconnectGamepadsLocked();
|
||||
Status ReleasePressedInputsLocked();
|
||||
int32_t AllocatePointerIdLocked() const;
|
||||
std::vector<ActiveTouch>::iterator FindTouchIteratorLocked(int64_t touch_id,
|
||||
@@ -170,6 +232,7 @@ class RobloxInputRouter final {
|
||||
std::vector<int32_t> active_mouse_buttons_;
|
||||
std::vector<ActiveTouch> active_touches_;
|
||||
std::vector<ActiveKey> active_keys_;
|
||||
std::vector<ActiveGamepad> gamepads_;
|
||||
float mouse_x_ = 0.0f;
|
||||
float mouse_y_ = 0.0f;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
|
||||
struct RobloxPermissionsMessageBusSymbols {
|
||||
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
|
||||
jboolean) = nullptr;
|
||||
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
|
||||
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
|
||||
jstring) = nullptr;
|
||||
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return subscribe_request && delete_connection && publish_response &&
|
||||
set_async_handler && clear_handler && call_response_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxPermissionsMessageBusObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring)) = nullptr;
|
||||
void (*clear_raw_callback)(void*, jobject) = nullptr;
|
||||
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_async_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_raw_callback &&
|
||||
clear_raw_callback && create_async_handler && clear_async_handler;
|
||||
}
|
||||
};
|
||||
|
||||
// Installs both legacy protocol subscriptions and correlated async handlers
|
||||
// before native bootstrap. MICROPHONE_ACCESS follows host capture
|
||||
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
|
||||
// Android APK. No query/request opens an audio device, enables a disabled
|
||||
// microphone, or changes Roblox account eligibility. Other capabilities remain
|
||||
// unsupported.
|
||||
class RobloxPermissionsBridge final {
|
||||
public:
|
||||
RobloxPermissionsBridge(JniEnvironmentProvider environment,
|
||||
RobloxPermissionsMessageBusSymbols symbols,
|
||||
RobloxPermissionsMessageBusObjects objects,
|
||||
bool microphone_enabled);
|
||||
~RobloxPermissionsBridge();
|
||||
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
|
||||
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxPermissionsMessageBusSymbols symbols_;
|
||||
const RobloxPermissionsMessageBusObjects objects_;
|
||||
const bool microphone_enabled_;
|
||||
// Serializes lifecycle only. Native dispatch never holds this mutex.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
@@ -2,6 +2,7 @@
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
@@ -13,9 +14,11 @@ namespace runtime {
|
||||
struct RobloxPlatformWebSymbols {
|
||||
RobloxWebViewMessageBusSymbols web_view;
|
||||
RobloxBrowserServiceSymbols browser_service;
|
||||
RobloxPermissionsMessageBusSymbols permissions;
|
||||
|
||||
bool complete() const {
|
||||
return web_view.complete() && browser_service.complete();
|
||||
return web_view.complete() && browser_service.complete() &&
|
||||
permissions.complete();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -14,8 +15,14 @@ namespace runtime {
|
||||
struct RobloxTextOverlayViewport {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
float density_scale = 1.0F;
|
||||
|
||||
bool valid() const { return width > 0 && height > 0; }
|
||||
bool valid() const {
|
||||
return width > 0 && height > 0 && std::isfinite(density_scale) &&
|
||||
density_scale > 0.0F;
|
||||
}
|
||||
int coordinate_width() const;
|
||||
int coordinate_height() const;
|
||||
};
|
||||
|
||||
struct RobloxTextOverlayGeometry {
|
||||
@@ -57,10 +64,13 @@ struct RobloxTextOverlayPresentation {
|
||||
uint64_t generation = 0;
|
||||
RobloxTextOverlayGeometry geometry;
|
||||
float font_size = 0.0F;
|
||||
bool multiline = false;
|
||||
int32_t x_alignment = 0;
|
||||
int32_t y_alignment = 1;
|
||||
int32_t text_color = 0;
|
||||
int32_t font = 0;
|
||||
int32_t text_input_type = 0;
|
||||
bool text_wrapped = false;
|
||||
std::string display_utf8;
|
||||
std::size_t caret_utf8_byte = 0;
|
||||
std::size_t selection_begin_utf8_byte = 0;
|
||||
|
||||
@@ -67,10 +67,13 @@ struct RobloxTextDisplayUpdate {
|
||||
int32_t area_width = 0;
|
||||
int32_t area_height = 0;
|
||||
float font_size = 0.0F;
|
||||
bool multiline = false;
|
||||
int32_t x_alignment = 0;
|
||||
int32_t y_alignment = 1;
|
||||
int32_t text_color = 0;
|
||||
int32_t font = 0;
|
||||
int32_t text_input_type = 0;
|
||||
bool text_wrapped = false;
|
||||
const char* utf8 = nullptr;
|
||||
std::size_t utf8_size = 0;
|
||||
int32_t cursor_utf16 = 0;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Android's RbxKeyboard resolves the Roblox Font enum through
|
||||
// assets/android/fonts/font-mappings.json. The selected file lives below
|
||||
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
|
||||
struct RobloxTextFontSelection {
|
||||
std::string primary_file;
|
||||
float size_scale = 0.795F;
|
||||
bool exact_mapping = false;
|
||||
};
|
||||
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(
|
||||
int32_t font, const std::string& asset_content_root);
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(int32_t font);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
|
||||
@@ -27,6 +27,7 @@ class RobloxTextSurfaceOverlay final {
|
||||
RobloxTextSurfaceOverlay& operator=(const RobloxTextSurfaceOverlay&) = delete;
|
||||
|
||||
Status Initialize(RobloxTextOverlayViewport viewport);
|
||||
Status UpdateViewport(RobloxTextOverlayViewport viewport);
|
||||
Status Shutdown();
|
||||
RobloxTextDisplaySink sink();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "mocktail/platform/sdl_gamepad_manager.h"
|
||||
#include "mocktail/platform/text_clipboard.h"
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_input_native_adapter.h"
|
||||
@@ -37,11 +38,16 @@ class RobloxWindowInputRuntime final {
|
||||
RobloxInputSnapshot Snapshot() const;
|
||||
|
||||
private:
|
||||
static void PlatformEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
static bool MouseLockQueryCallback(void* context, bool* locked_center);
|
||||
static void GamepadEventCallback(void* context,
|
||||
const platform::PlatformEvent& event);
|
||||
|
||||
RobloxTextSurfaceOverlay text_surface_overlay_;
|
||||
std::unique_ptr<platform::TextClipboard> text_clipboard_;
|
||||
RobloxInputRuntime runtime_;
|
||||
platform::SdlGamepadManager gamepads_;
|
||||
bool observer_registered_ = false;
|
||||
bool mouse_lock_query_registered_ = false;
|
||||
bool initialized_ = false;
|
||||
|
||||
@@ -28,6 +28,10 @@ struct WindowConfig {
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
std::string title = "Roblox";
|
||||
// GNOME desktop dimensions are logical units; keep the render surface at
|
||||
// that size unless the user explicitly requests high-density rendering.
|
||||
bool high_dpi = false;
|
||||
bool high_dpi_valid = true;
|
||||
};
|
||||
|
||||
struct InputCapabilityConfig {
|
||||
@@ -37,6 +41,7 @@ struct InputCapabilityConfig {
|
||||
};
|
||||
|
||||
struct NetworkProxyConfig {
|
||||
std::string scheme = "http";
|
||||
std::string host;
|
||||
int port = 0;
|
||||
};
|
||||
@@ -61,7 +66,8 @@ struct DiscordRpcConfig {
|
||||
};
|
||||
|
||||
std::optional<NetworkProxyConfig> ParseNetworkProxyConfig(
|
||||
std::string_view host, std::string_view port);
|
||||
std::string_view host, std::string_view port,
|
||||
std::string_view scheme = "http");
|
||||
std::string BuildNetworkProxyUrl(const NetworkProxyConfig& proxy);
|
||||
|
||||
// Immutable, supported runtime options. Advanced MOCKTAIL_PATCH_* controls
|
||||
@@ -79,6 +85,11 @@ class RuntimeConfig {
|
||||
return graphics_backend_name_;
|
||||
}
|
||||
const WindowConfig& window() const { return window_; }
|
||||
const std::string& theme_mode() const { return theme_mode_; }
|
||||
bool theme_mode_valid() const {
|
||||
return theme_mode_ == "roblox" || theme_mode_ == "system" ||
|
||||
theme_mode_ == "light" || theme_mode_ == "dark";
|
||||
}
|
||||
const InputCapabilityConfig& input_capabilities() const {
|
||||
return input_capabilities_;
|
||||
}
|
||||
@@ -97,9 +108,17 @@ class RuntimeConfig {
|
||||
return audio_output_device_;
|
||||
}
|
||||
bool audio_output_device_valid() const { return audio_output_device_valid_; }
|
||||
const std::string& audio_input_device() const { return audio_input_device_; }
|
||||
bool audio_input_device_valid() const { return audio_input_device_valid_; }
|
||||
bool microphone_enabled() const { return audio_input_device_ != "disabled"; }
|
||||
const std::optional<NetworkProxyConfig>& network_proxy() const {
|
||||
return network_proxy_;
|
||||
}
|
||||
const std::optional<std::filesystem::path>& ca_bundle() const {
|
||||
return ca_bundle_;
|
||||
}
|
||||
bool ca_bundle_valid() const { return ca_bundle_valid_; }
|
||||
bool use_system_proxy() const { return use_system_proxy_; }
|
||||
const DiscordRpcConfig& discord_rpc() const { return discord_rpc_; }
|
||||
bool discord_rpc_valid() const { return discord_rpc_valid_; }
|
||||
|
||||
@@ -120,6 +139,7 @@ class RuntimeConfig {
|
||||
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
|
||||
std::string graphics_backend_name_ = "direct-vulkan";
|
||||
WindowConfig window_;
|
||||
std::string theme_mode_ = "roblox";
|
||||
InputCapabilityConfig input_capabilities_;
|
||||
DeviceProfile device_profile_ = *FindDeviceProfile(kDefaultDeviceProfileName);
|
||||
bool device_profile_valid_ = true;
|
||||
@@ -129,7 +149,12 @@ class RuntimeConfig {
|
||||
PerformancePolicy performance_;
|
||||
std::string audio_output_device_ = "default";
|
||||
bool audio_output_device_valid_ = true;
|
||||
std::string audio_input_device_ = "default";
|
||||
bool audio_input_device_valid_ = true;
|
||||
bool use_system_proxy_ = false;
|
||||
std::optional<NetworkProxyConfig> network_proxy_;
|
||||
std::optional<std::filesystem::path> ca_bundle_;
|
||||
bool ca_bundle_valid_ = true;
|
||||
DiscordRpcConfig discord_rpc_;
|
||||
bool discord_rpc_valid_ = true;
|
||||
std::vector<std::string> unsafe_detached_thread_overrides_;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_SYSTEM_PROXY_H_
|
||||
#define MOCKTAIL_RUNTIME_SYSTEM_PROXY_H_
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/runtime_config.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
struct SystemProxyResult {
|
||||
std::optional<NetworkProxyConfig> proxy;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// Selects the first proxy returned by the host resolver. An empty `proxy`
|
||||
// means that the resolver selected a direct connection.
|
||||
SystemProxyResult SelectSystemProxy(
|
||||
const std::vector<std::string>& candidates);
|
||||
|
||||
// Resolves the host's proxy policy for Roblox HTTPS traffic. GIO delegates to
|
||||
// the desktop/environment proxy resolver, including PAC when it is available.
|
||||
// HTTP/HTTPS resolver results and SOCKS5 proxy endpoints are supported.
|
||||
SystemProxyResult ResolveSystemProxy();
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_SYSTEM_PROXY_H_
|
||||
@@ -0,0 +1,32 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_TEXTURE_MEMORY_POLICY_H_
|
||||
#define MOCKTAIL_RUNTIME_TEXTURE_MEMORY_POLICY_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Roblox sizes its texture budget for the Android guest, so a PC host still
|
||||
// gets caps.videoMemory = 64 MiB. These publish a host-sized budget through
|
||||
// Roblox's own FIntRenderForceVideoMemorySize override.
|
||||
|
||||
// Total usable host memory in bytes, or 0 when it cannot be read.
|
||||
std::uint64_t DetectHostMemoryBytes();
|
||||
|
||||
// 0 leaves Roblox's budget alone; otherwise an eighth of host memory, clamped
|
||||
// to a PC-class range.
|
||||
std::uint64_t CalculateTextureMemoryBudgetBytes(std::uint64_t host_memory_bytes);
|
||||
|
||||
// A 0 budget, or a key already present, is preserved: an explicit fflags.json
|
||||
// or environment override wins.
|
||||
bool MergeTextureMemoryClientSettingsOverrides(std::uint64_t budget_bytes,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_TEXTURE_MEMORY_POLICY_H_
|
||||
@@ -53,6 +53,7 @@ enum class WebViewHelperEventType : unsigned char {
|
||||
kExecuteRoblox = 1,
|
||||
kRobloxWkHybrid = 2,
|
||||
kReady = 3,
|
||||
kRobloxCookie = 4,
|
||||
};
|
||||
|
||||
struct WebViewHelperEvent {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#ifndef MOCKTAIL_UPDATE_APK_PROVIDER_H_
|
||||
#define MOCKTAIL_UPDATE_APK_PROVIDER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::update {
|
||||
|
||||
struct ProviderVersion {
|
||||
std::string version_name;
|
||||
std::uint64_t version_code = 0;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
struct ProviderDownloadResult {
|
||||
std::vector<std::filesystem::path> archives;
|
||||
// Provider that produced the archives, so a stored payload can be traced
|
||||
// back to where it came from.
|
||||
std::string source;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// A source of Roblox APK archives. Providers are interchangeable: whichever
|
||||
// one answers first wins, and every archive still passes the same signature,
|
||||
// Build-ID, and ABI verification afterwards.
|
||||
class ApkProvider {
|
||||
public:
|
||||
virtual ~ApkProvider() = default;
|
||||
|
||||
// Short stable identifier, recorded in payload receipts and error messages.
|
||||
virtual std::string_view name() const = 0;
|
||||
|
||||
// Latest published version. A provider that only serves pinned versions
|
||||
// reports an error here and stays usable through DownloadExact.
|
||||
virtual ProviderVersion CheckLatest() const = 0;
|
||||
|
||||
virtual ProviderDownloadResult DownloadExact(
|
||||
std::string_view version, const std::filesystem::path& output_directory,
|
||||
int progress_fd) const = 0;
|
||||
};
|
||||
|
||||
// Tries every provider in order. One unreachable provider is the most common
|
||||
// first-run failure, and on its own it must not fail the whole update.
|
||||
class ProviderChain final : public ApkProvider {
|
||||
public:
|
||||
void Add(std::unique_ptr<ApkProvider> provider);
|
||||
bool empty() const { return providers_.empty(); }
|
||||
|
||||
std::string_view name() const override { return "provider-chain"; }
|
||||
ProviderVersion CheckLatest() const override;
|
||||
ProviderDownloadResult DownloadExact(
|
||||
std::string_view version, const std::filesystem::path& output_directory,
|
||||
int progress_fd) const override;
|
||||
|
||||
private:
|
||||
std::vector<std::unique_ptr<ApkProvider>> providers_;
|
||||
};
|
||||
|
||||
} // namespace mocktail::update
|
||||
|
||||
#endif // MOCKTAIL_UPDATE_APK_PROVIDER_H_
|
||||
@@ -1,42 +1,30 @@
|
||||
#ifndef MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_
|
||||
#define MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "update/apk_provider.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
|
||||
struct ProviderVersion {
|
||||
std::string version_name;
|
||||
std::uint64_t version_code = 0;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
struct ProviderDownloadResult {
|
||||
std::vector<std::filesystem::path> archives;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
ProviderVersion ParseApkPureLatestMetadata(std::string_view metadata);
|
||||
|
||||
std::vector<std::string> ParseApkPureExactDownloadUrls(
|
||||
std::string_view metadata, std::string_view version,
|
||||
std::string* error = nullptr);
|
||||
|
||||
class ApkPureProvider final {
|
||||
class ApkPureProvider final : public ApkProvider {
|
||||
public:
|
||||
ProviderVersion CheckLatest() const;
|
||||
std::string_view name() const override { return "apk-pure"; }
|
||||
|
||||
ProviderVersion CheckLatest() const override;
|
||||
|
||||
ProviderDownloadResult DownloadExact(
|
||||
std::string_view version, const std::filesystem::path& output_directory,
|
||||
int progress_fd = -1) const;
|
||||
int progress_fd = -1) const override;
|
||||
};
|
||||
|
||||
} // namespace mocktail::update
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::update {
|
||||
|
||||
struct HostAbiDerivationOptions {
|
||||
std::filesystem::path reference_library;
|
||||
std::filesystem::path reference_profile;
|
||||
std::vector<std::filesystem::path> reference_compatibility_manifests;
|
||||
std::filesystem::path candidate_payload_directory;
|
||||
std::filesystem::path output_directory;
|
||||
};
|
||||
@@ -23,6 +25,22 @@ struct HostAbiDerivationResult {
|
||||
}
|
||||
};
|
||||
|
||||
// Identity an exact HostAbi sidecar was derived for. It only validates
|
||||
// against the one library whose bytes it describes, so the reference payload
|
||||
// has to be selected by this Build ID rather than by catalog order.
|
||||
struct HostAbiSidecarIdentity {
|
||||
std::string elf_build_id;
|
||||
std::string payload_id;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const {
|
||||
return error.empty() && !elf_build_id.empty() && !payload_id.empty();
|
||||
}
|
||||
};
|
||||
|
||||
HostAbiSidecarIdentity ReadHostAbiSidecarIdentity(
|
||||
const std::filesystem::path& sidecar);
|
||||
|
||||
// Derives an exact candidate HostAbi profile by matching normalized x86-64
|
||||
// instruction signatures against one already approved reference payload. This
|
||||
// is static analysis only; the result still requires two real Tier C canaries
|
||||
|
||||
@@ -15,7 +15,18 @@ struct HttpTransferRequest {
|
||||
std::vector<std::string> allowed_hosts;
|
||||
std::size_t maximum_bytes = 0;
|
||||
long connect_timeout_ms = 10000;
|
||||
// Hard ceiling for one attempt; zero means none. A several hundred MiB
|
||||
// archive over a slow link is not a failed transfer, so the ceiling is the
|
||||
// wrong tool for it - a transfer that actually stops is caught by the
|
||||
// low-speed guard below, in a minute rather than in a quarter of an hour.
|
||||
long transfer_timeout_ms = 120000;
|
||||
long low_speed_bytes_per_second = 1024;
|
||||
long low_speed_seconds = 60;
|
||||
// Transient failures - a provider 503, a dropped connection, a stall - are
|
||||
// retried. A file transfer resumes from the bytes already on disk instead
|
||||
// of pulling the whole archive again.
|
||||
int maximum_attempts = 3;
|
||||
long retry_delay_ms = 2000;
|
||||
int maximum_redirects = 5;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,32 @@
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
struct evp_md_ctx_st;
|
||||
|
||||
namespace mocktail::update {
|
||||
|
||||
class FileDigest final {
|
||||
public:
|
||||
FileDigest();
|
||||
~FileDigest();
|
||||
FileDigest(const FileDigest&) = delete;
|
||||
FileDigest& operator=(const FileDigest&) = delete;
|
||||
|
||||
bool valid() const noexcept { return context_ != nullptr; }
|
||||
bool Update(const void* data, std::size_t size);
|
||||
bool Update(std::string_view bytes);
|
||||
std::string FinalHex(std::size_t max_chars = 0);
|
||||
|
||||
private:
|
||||
struct evp_md_ctx_st* context_ = nullptr;
|
||||
};
|
||||
|
||||
std::string HashText(std::string_view text, std::string* error = nullptr);
|
||||
std::string HashText(std::string_view text, std::size_t max_hex_chars,
|
||||
std::string* error = nullptr);
|
||||
|
||||
struct PayloadMetadata {
|
||||
std::string package_name;
|
||||
std::string version_name;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::update {
|
||||
|
||||
@@ -24,6 +25,14 @@ struct PayloadStoreResult {
|
||||
}
|
||||
};
|
||||
|
||||
struct PayloadGarbageResult {
|
||||
std::vector<std::string> removed;
|
||||
std::uintmax_t freed_bytes = 0;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
class PayloadStore final {
|
||||
public:
|
||||
PayloadStore(std::filesystem::path root,
|
||||
@@ -41,6 +50,10 @@ class PayloadStore final {
|
||||
PayloadStoreResult InspectCurrent() const;
|
||||
PayloadStoreResult VerifyCurrent();
|
||||
PayloadStoreResult Rollback();
|
||||
// Deletes payload trees nothing points at any more. The active payload, the
|
||||
// rollback target, and every ID in `keep` survive; a payload tree is around
|
||||
// 600 MiB, so without this the store grows by one release forever.
|
||||
PayloadGarbageResult CollectGarbage(const std::vector<std::string>& keep);
|
||||
std::string StatusJson(std::string* error = nullptr) const;
|
||||
|
||||
private:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#ifndef MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
|
||||
#define MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::update {
|
||||
|
||||
// Launches a downloaded latest payload once without promoting it to the active
|
||||
// store. A non-empty HostAbi profile puts the child in explicit candidate mode;
|
||||
// normal startup approval is intentionally not created or modified.
|
||||
struct UnsafeLatestRunOptions {
|
||||
std::filesystem::path runtime_binary;
|
||||
std::filesystem::path payload_directory;
|
||||
std::filesystem::path compatibility_manifest;
|
||||
std::filesystem::path host_abi_profile;
|
||||
|
||||
// Tests may supply a deterministic environment. Production callers leave
|
||||
// this empty to inherit the updater process environment.
|
||||
std::vector<std::string> inherited_environment;
|
||||
};
|
||||
|
||||
struct UnsafeLatestRunResult {
|
||||
int exit_code = -1;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const { return error.empty() && exit_code == 0; }
|
||||
};
|
||||
|
||||
UnsafeLatestRunResult RunUnsafeLatestCandidate(
|
||||
const UnsafeLatestRunOptions& options);
|
||||
|
||||
} // namespace mocktail::update
|
||||
|
||||
#endif // MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
|
||||
@@ -2,9 +2,14 @@
|
||||
#define MOCKTAIL_UPDATE_UPDATE_COORDINATOR_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "update/apk_bundle.h"
|
||||
#include "update/apk_provider.h"
|
||||
#include "update/compatibility_catalog.h"
|
||||
#include "update/payload_store.h"
|
||||
#include "update/readiness_canary.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -16,6 +21,9 @@ struct UpdatePaths {
|
||||
std::filesystem::path state_root;
|
||||
std::filesystem::path compatibility_manifest;
|
||||
std::filesystem::path signing_trust_manifest;
|
||||
// One exact HostAbi sidecar, or a directory of them. A directory lets a new
|
||||
// supported profile ship its own reference instead of inheriting whichever
|
||||
// build the single file happens to describe.
|
||||
std::filesystem::path host_abi_reference_profile;
|
||||
std::filesystem::path runtime_binary;
|
||||
};
|
||||
@@ -24,6 +32,9 @@ struct UpdateRequest {
|
||||
bool startup_preflight = false;
|
||||
bool check_latest = true;
|
||||
bool run_canary = true;
|
||||
// Explicit manual escape hatch. It launches the provider latest once with a
|
||||
// derived candidate profile and never promotes it into current.json.
|
||||
bool force_run_latest = false;
|
||||
CanaryGraphicsBackend canary_graphics_backend =
|
||||
CanaryGraphicsBackend::kDirectVulkan;
|
||||
int progress_fd = -1;
|
||||
@@ -31,6 +42,10 @@ struct UpdateRequest {
|
||||
|
||||
struct UpdateResult {
|
||||
bool changed = false;
|
||||
// The provider publishes a newer Roblox than the payload that stayed
|
||||
// active. Launching still works, but the Roblox servers reject an outdated
|
||||
// client, so this is a user-visible outcome rather than a quiet success.
|
||||
bool stale = false;
|
||||
std::string payload_id;
|
||||
std::string message;
|
||||
std::vector<std::string> warnings;
|
||||
@@ -39,6 +54,35 @@ struct UpdateResult {
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// One payload worth trying, best first. The installed payload is deliberately
|
||||
// not a plan entry: it is what remains when every plan fails, so a rejected
|
||||
// candidate cannot pin the user to a version the catalog has moved past.
|
||||
struct UpdateCandidatePlan {
|
||||
ExpectedPayloadIdentity identity;
|
||||
bool exact_supported = false;
|
||||
// The store already holds an approved profile for this exact version, so it
|
||||
// only needs its canaries re-run rather than another few hundred MiB.
|
||||
bool reuse_installed = false;
|
||||
std::string origin;
|
||||
};
|
||||
|
||||
// Newest supported profile whose own bytes an available sidecar describes,
|
||||
// with `sidecar` set to that file. `location` is one sidecar or a directory
|
||||
// of them. A library picked any other way cannot pass the derivation's own
|
||||
// reference check.
|
||||
std::optional<SupportedPayloadProfile> ResolveReferenceProfile(
|
||||
const std::filesystem::path& location,
|
||||
const std::vector<SupportedPayloadProfile>& profiles,
|
||||
std::filesystem::path* sidecar);
|
||||
|
||||
// `latest` is null when the provider could not be consulted. `current` is the
|
||||
// runnable payload (empty when there is none) and `installed` is whatever the
|
||||
// activation manifest points at, runnable or not.
|
||||
std::vector<UpdateCandidatePlan> PlanUpdateCandidates(
|
||||
const std::vector<SupportedPayloadProfile>& profiles,
|
||||
const SupportedPayloadProfile& preferred, const ProviderVersion* latest,
|
||||
const PayloadStoreResult& current, const PayloadStoreResult& installed);
|
||||
|
||||
UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request);
|
||||
|
||||
} // namespace mocktail::update
|
||||
|
||||
+24
-3
@@ -1,8 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -euo pipefail
|
||||
set -eu
|
||||
|
||||
APPDIR="${APPDIR:-$(CDPATH= cd -P "$(dirname -- "$0")" && pwd)}"
|
||||
|
||||
InitializeAnyLinuxPaths() {
|
||||
# quick-sharun preserves our custom AppRun, so its generated AppRun never
|
||||
# runs these relocation hooks. WebKit and p11-kit use the patched paths
|
||||
# even when WEBKIT_EXEC_PATH and SSL_CERT_FILE are set.
|
||||
for anylinux_hook in 01-check-ca-certs.hook 01-path-mapping-hardcoded.hook; do
|
||||
if [ -f "${APPDIR}/bin/${anylinux_hook}" ]; then
|
||||
. "${APPDIR}/bin/${anylinux_hook}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
if [ -n "${SHARUN_DIR:-}" ] && [ -x "${APPDIR}/bin/bash" ]; then
|
||||
export PATH="${APPDIR}/bin:${PATH}"
|
||||
export MOCKTAIL_ANYLINUX_BIN_DIR="${APPDIR}/bin"
|
||||
export MOCKTAIL_BUNDLE_ROOT="${APPDIR}/usr/share/mocktail-bundle"
|
||||
InitializeAnyLinuxPaths
|
||||
exec "${APPDIR}/bin/bash" \
|
||||
"${MOCKTAIL_BUNDLE_ROOT}/mocktail/scripts/portable_launcher.sh" "$@"
|
||||
fi
|
||||
|
||||
APPDIR="${APPDIR:-$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
|
||||
exec "${APPDIR}/usr/share/mocktail-bundle/run.sh" "$@"
|
||||
@@ -0,0 +1,11 @@
|
||||
# pkgforge-dev/archlinux-pkgs-debloated, continuous release, 2026-09-11.
|
||||
# These assets are rolling. Refresh names/hashes together; do not bypass a
|
||||
# checksum mismatch. Only the disposable AppImage build container installs them.
|
||||
19e57cf78e309052b1ce55293374599f19fbc56cd2ac572191f475e718b42503 ffmpeg-mini-x86_64.pkg.tar.zst
|
||||
5a796f6846a898d5f706d92ae34a70d6f6cf28595369ccafbfda21ad6ccc6b7f gdk-pixbuf2-mini-x86_64.pkg.tar.zst
|
||||
9a42b41a03fc493a9bc4d00c9882e34ee28b5239a79175cc47e1dfe869f5d4a6 gtk4-mini-x86_64.pkg.tar.zst
|
||||
8bec7844a49eb7e3efc020ba0858e69cfb4fed64b83c116df04b4179cc2fa1a6 icu-mini-x86_64.pkg.tar.zst
|
||||
91fd7550208a306ff4e43fe10f405db01f2bc1ce8bdad5469229c05185201983 librsvg-mini-x86_64.pkg.tar.zst
|
||||
d5220136f8d9b4eaaf173a44d3f0092e97b61cfe9f5d627c570c6e81add9e789 libxml2-mini-x86_64.pkg.tar.zst
|
||||
90ec938ec71e62478745185f76d64253cc0a7ac2e0a18aaff0fb59cd70eb1f92 mesa-mini-x86_64.pkg.tar.zst
|
||||
857f7d895a471786b7920b430fbf0b91078bfb8efc6bf332c27928f6104929ae opus-mini-x86_64.pkg.tar.zst
|
||||
@@ -0,0 +1,21 @@
|
||||
# Paths are expanded by sharun after the AppImage is mounted. Keep the
|
||||
# Android ABI adapters separate from the host-ABI toolkit libraries.
|
||||
USE_HOST_XDG_CACHE_HOME=1
|
||||
GSETTINGS_BACKEND=keyfile
|
||||
SHARUN_ALLOW_SYS_VKICD=1
|
||||
MOCKTAIL_PORTABLE_MODE=standalone
|
||||
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
|
||||
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
|
||||
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
|
||||
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
|
||||
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
|
||||
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
|
||||
MOCKTAIL_UPDATE_PROGRESS_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
|
||||
MOCKTAIL_FREEBSD_SOCKET_HELPER=${SHARUN_DIR}/share/mocktail/helpers/mocktail_freebsd_socket_helper
|
||||
# sharun reloads .env for every child. Publish a default separately so it
|
||||
# cannot overwrite a candidate-specific manifest supplied by the updater.
|
||||
MOCKTAIL_PACKAGED_COMPATIBILITY_MANIFEST=${SHARUN_DIR}/share/mocktail/metadata/roblox_compatibility.json
|
||||
MOCKTAIL_UPDATE_COMPATIBILITY_PATH=${SHARUN_DIR}/share/mocktail/metadata/roblox_compatibility.json
|
||||
MOCKTAIL_UPDATE_SIGNING_TRUST_PATH=${SHARUN_DIR}/share/mocktail/metadata/roblox_signing_certificates.json
|
||||
MOCKTAIL_UPDATE_HOST_ABI_REFERENCE=${SHARUN_DIR}/share/mocktail/metadata/roblox_host_abi_reference.json
|
||||
MOCKTAIL_BOOTSTRAP_SOURCES_PATH=${SHARUN_DIR}/share/mocktail/metadata/roblox_bootstrap_sources.json
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=1.0.3
|
||||
pkgrel=1
|
||||
pkgrel=2
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pkgbase = mocktail-bin
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
||||
pkgver = 1.0.2
|
||||
pkgrel = 1
|
||||
pkgver = 1.0.3
|
||||
pkgrel = 2
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
@@ -31,10 +31,10 @@ pkgbase = mocktail-bin
|
||||
provides = mocktail
|
||||
conflicts = mocktail
|
||||
conflicts = mocktail-git
|
||||
noextract = mocktail-1.0.2-1-x86_64.pkg.tar.zst
|
||||
noextract = mocktail-1.0.3-2-x86_64.pkg.tar.zst
|
||||
options = !debug
|
||||
options = !strip
|
||||
source = mocktail-1.0.2-1-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.2/mocktail-1.0.2-1-x86_64.pkg.tar.zst
|
||||
sha256sums = d84673606728150aec96c950051efa87598314d59f800e2a7d8731d74cd617d9
|
||||
source = mocktail-1.0.3-2-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.3/mocktail-1.0.3-2-x86_64.pkg.tar.zst
|
||||
sha256sums = be945181d73d28d9bf09a93927a215d03a3a8b479ffddab3378a8113b56e4b99
|
||||
|
||||
pkgname = mocktail-bin
|
||||
@@ -1,8 +1,8 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-bin
|
||||
pkgver=1.0.2
|
||||
pkgrel=1
|
||||
pkgver=1.0.3
|
||||
pkgrel=2
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
@@ -38,7 +38,7 @@ options=('!debug' '!strip')
|
||||
_archive="mocktail-${pkgver}-${pkgrel}-${CARCH}.pkg.tar.zst"
|
||||
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
|
||||
noextract=("${_archive}")
|
||||
sha256sums=('d84673606728150aec96c950051efa87598314d59f800e2a7d8731d74cd617d9')
|
||||
sha256sums=('be945181d73d28d9bf09a93927a215d03a3a8b479ffddab3378a8113b56e4b99')
|
||||
|
||||
package() {
|
||||
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pkgbase = mocktail
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 1.0.3
|
||||
pkgrel = 1
|
||||
pkgrel = 2
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
arch = x86_64
|
||||
license = Apache-2.0
|
||||
@@ -38,6 +38,7 @@ pkgbase = mocktail
|
||||
conflicts = mocktail-bin
|
||||
conflicts = mocktail-git
|
||||
options = !debug
|
||||
options = !lto
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.3
|
||||
sha256sums = SKIP
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=1.0.3
|
||||
pkgrel=1
|
||||
pkgrel=2
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/komaruworld/mocktail'
|
||||
@@ -42,7 +42,8 @@ makedepends=(
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
conflicts=('mocktail-bin' 'mocktail-git')
|
||||
options=('!debug')
|
||||
# The 1.0.3 tag predates the source-level FORTIFY/LTO isolation fix.
|
||||
options=('!debug' '!lto')
|
||||
source=(
|
||||
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
|
||||
)
|
||||
|
||||
@@ -27,20 +27,36 @@ else
|
||||
BUNDLE_ROOT="$(cd -P -- "${LAUNCHER_DIR}/../.." && pwd)"
|
||||
fi
|
||||
RUNTIME_ROOT="${BUNDLE_ROOT}/mocktail"
|
||||
BIN_DIR="${RUNTIME_ROOT}/bin"
|
||||
BUNDLED_BIN_DIR="${RUNTIME_ROOT}/bin"
|
||||
ANYLINUX_BIN_DIR="${MOCKTAIL_ANYLINUX_BIN_DIR:-}"
|
||||
if [[ -n "${ANYLINUX_BIN_DIR}" ]]; then
|
||||
[[ "${ANYLINUX_BIN_DIR}" == /* && -d "${ANYLINUX_BIN_DIR}" ]] || {
|
||||
printf 'mocktail: invalid AnyLinux binary directory: %s\n' \
|
||||
"${ANYLINUX_BIN_DIR}" >&2
|
||||
exit 1
|
||||
}
|
||||
BIN_DIR="${ANYLINUX_BIN_DIR}"
|
||||
else
|
||||
BIN_DIR="${BUNDLED_BIN_DIR}"
|
||||
fi
|
||||
MAIN_BINARY="${BIN_DIR}/mocktail"
|
||||
UPDATE_HELPER="${BIN_DIR}/mocktail_updater"
|
||||
FAILURE_DIALOG_HELPER="${BIN_DIR}/mocktail_failure_dialog"
|
||||
WEBVIEW_HELPER="${BIN_DIR}/mocktail_webview_helper"
|
||||
FREEBSD_SOCKET_HELPER="${BIN_DIR}/mocktail_freebsd_socket_helper"
|
||||
FREEBSD_SOCKET_HELPER="${BUNDLED_BIN_DIR}/mocktail_freebsd_socket_helper"
|
||||
ANDROID_BUILD_TOOLS="${RUNTIME_ROOT}/runtime/android-tools/bin"
|
||||
ANDROID_TOOL_EXEC_DIR="${ANYLINUX_BIN_DIR:-${ANDROID_BUILD_TOOLS}}"
|
||||
METADATA_DIR="${RUNTIME_ROOT}/metadata"
|
||||
ABI_MANIFEST="${METADATA_DIR}/ABI.txt"
|
||||
DEPENDENCY_MANIFEST="${METADATA_DIR}/DEPENDENCIES.txt"
|
||||
CHECKSUM_MANIFEST="${METADATA_DIR}/SHA256SUMS.txt"
|
||||
SUPPORT_ROOT="${RUNTIME_ROOT}/runtime"
|
||||
SUPPORT_BIN="${SUPPORT_ROOT}/bin"
|
||||
export PATH="${SUPPORT_BIN}:${ANDROID_BUILD_TOOLS}:${PATH}"
|
||||
if [[ -n "${ANYLINUX_BIN_DIR}" ]]; then
|
||||
SUPPORT_BIN="${ANYLINUX_BIN_DIR}"
|
||||
else
|
||||
SUPPORT_BIN="${SUPPORT_ROOT}/bin"
|
||||
fi
|
||||
export PATH="${BIN_DIR}:${SUPPORT_BIN}:${ANDROID_BUILD_TOOLS}:${PATH}"
|
||||
export LD_LIBRARY_PATH="${RUNTIME_ROOT}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
||||
export MOCKTAIL_FREEBSD_SOCKET_HELPER="${FREEBSD_SOCKET_HELPER}"
|
||||
|
||||
@@ -203,17 +219,18 @@ ConfigureStandaloneEnvironment() {
|
||||
Die "standalone WebKit environment is incomplete"
|
||||
[[ "${WEBKITGTK6_NAMESPACE_DIR}" =~ ^/usr/(lib|lib64|libexec|lib/x86_64-linux-gnu)/webkitgtk-6\.0$ ]] ||
|
||||
Die "standalone WebKitGTK 6 namespace path is invalid"
|
||||
export PYTHONHOME="${SUPPORT_ROOT}/python"
|
||||
unset PYTHONPATH PYTHONUSERBASE
|
||||
export PYTHONNOUSERSITE=1 PYTHONDONTWRITEBYTECODE=1
|
||||
export JAVA_HOME="${SUPPORT_ROOT}/jre"
|
||||
export SSL_CERT_FILE="${SUPPORT_ROOT}/share/ca-certificates/ca-bundle.crt"
|
||||
export REQUESTS_CA_BUNDLE="${SSL_CERT_FILE}"
|
||||
export WEBKIT_EXEC_PATH="${RUNTIME_ROOT}/${webkit6_exec}"
|
||||
if [[ -n "${ANYLINUX_BIN_DIR}" ]]; then
|
||||
export WEBKIT_EXEC_PATH="${ANYLINUX_BIN_DIR}"
|
||||
export GST_PLUGIN_SCANNER="${ANYLINUX_BIN_DIR}/gst-plugin-scanner"
|
||||
else
|
||||
export WEBKIT_EXEC_PATH="${RUNTIME_ROOT}/${webkit6_exec}"
|
||||
export GST_PLUGIN_SCANNER="${RUNTIME_ROOT}/${gst_scanner}"
|
||||
fi
|
||||
export WEBKIT_INJECTED_BUNDLE_PATH="$(dirname -- \
|
||||
"${RUNTIME_ROOT}/${webkit6_bundle}")"
|
||||
export GST_PLUGIN_PATH_1_0="${RUNTIME_ROOT}/${gst_plugins}"
|
||||
export GST_PLUGIN_SCANNER="${RUNTIME_ROOT}/${gst_scanner}"
|
||||
export GIO_EXTRA_MODULES="${RUNTIME_ROOT}/${gio_modules}"
|
||||
export GIO_USE_TLS="${gio_tls}"
|
||||
export GDK_PIXBUF_MODULE_FILE="${RUNTIME_ROOT}/${pixbuf_cache}"
|
||||
@@ -227,6 +244,7 @@ ConfigureStandaloneEnvironment() {
|
||||
|
||||
EnterStandaloneNamespace() {
|
||||
[[ "${ABI_MODE}" == standalone &&
|
||||
-z "${ANYLINUX_BIN_DIR}" &&
|
||||
"${MOCKTAIL_STANDALONE_NAMESPACE:-0}" != 1 &&
|
||||
"${MOCKTAIL_SKIP_NAMESPACE_CHECK:-0}" != 1 ]] || return 0
|
||||
local namespace_usr="${RUNTIME_ROOT}/namespace/usr"
|
||||
@@ -345,12 +363,14 @@ CheckBundleAbi() {
|
||||
[[ "$(uname -m)" == "${ABI_ARCHITECTURE}" ]] ||
|
||||
Die "bundle requires ${ABI_ARCHITECTURE}, host is $(uname -m)"
|
||||
local main_interpreter host_libc
|
||||
main_interpreter="$(ReadElfInterpreter "${MAIN_BINARY}")"
|
||||
main_interpreter="$(ReadElfInterpreter "${BUNDLED_BIN_DIR}/mocktail")"
|
||||
[[ "${main_interpreter}" == "${ABI_INTERPRETER}" ]] ||
|
||||
Die "mocktail ELF interpreter does not match ABI.txt (expected ${ABI_INTERPRETER}, found ${main_interpreter:-none})"
|
||||
ValidateBundledElfAbi "${MAIN_BINARY}" "mocktail"
|
||||
ValidateBundledElfAbi "${UPDATE_HELPER}" "Mocktail updater"
|
||||
ValidateBundledElfAbi "${WEBVIEW_HELPER}" "WebView helper"
|
||||
ValidateBundledElfAbi "${BUNDLED_BIN_DIR}/mocktail" "mocktail"
|
||||
ValidateBundledElfAbi "${BUNDLED_BIN_DIR}/mocktail_updater" \
|
||||
"Mocktail updater"
|
||||
ValidateBundledElfAbi "${BUNDLED_BIN_DIR}/mocktail_webview_helper" \
|
||||
"WebView helper"
|
||||
[[ -x "${ANDROID_BUILD_TOOLS}/aapt" ]] ||
|
||||
Die "Android APK metadata tool is unavailable"
|
||||
if [[ "${ABI_ANDROID_TOOLS_LIBC}" == java ]]; then
|
||||
@@ -361,11 +381,13 @@ CheckBundleAbi() {
|
||||
"Android aapt" glibc
|
||||
fi
|
||||
|
||||
host_libc="$(DetectHostLibc)"
|
||||
[[ "${host_libc}" != unknown ]] ||
|
||||
Die "cannot determine host libc ABI; install working getconf or ldd"
|
||||
[[ "${host_libc}" == "${ABI_LIBC}" ]] ||
|
||||
Die "bundle targets ${ABI_LIBC}, but host libc is ${host_libc}; use a Mocktail x86-64 ${host_libc} AppImage"
|
||||
if [[ -z "${ANYLINUX_BIN_DIR}" ]]; then
|
||||
host_libc="$(DetectHostLibc)"
|
||||
[[ "${host_libc}" != unknown ]] ||
|
||||
Die "cannot determine host libc ABI; install working getconf or ldd"
|
||||
[[ "${host_libc}" == "${ABI_LIBC}" ]] ||
|
||||
Die "bundle targets ${ABI_LIBC}, but host libc is ${host_libc}; use a Mocktail x86-64 ${host_libc} AppImage"
|
||||
fi
|
||||
}
|
||||
|
||||
CheckCommand() {
|
||||
@@ -377,15 +399,6 @@ CheckCommand() {
|
||||
return 1
|
||||
}
|
||||
|
||||
CheckPythonModule() {
|
||||
local name="$1"
|
||||
if python3 -c "import ${name}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
printf ' missing Python module: %s\n' "${name}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
CheckElfDependencies() {
|
||||
local executable="$1"
|
||||
local label="$2"
|
||||
@@ -415,29 +428,20 @@ CheckSystem() {
|
||||
fi
|
||||
|
||||
local command_name
|
||||
for command_name in bash python3 java jq unzip aapt apksigner file flock \
|
||||
for command_name in bash java jq unzip aapt apksigner file flock \
|
||||
timeout readelf sha256sum; do
|
||||
CheckCommand "${command_name}" || status=1
|
||||
done
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
CheckPythonModule yaml || status=1
|
||||
CheckPythonModule requests || status=1
|
||||
if ! python3 -c 'import capstone; raise SystemExit(capstone.cs_version()[0] != 5)' \
|
||||
>/dev/null 2>&1; then
|
||||
printf ' missing Python module: capstone 5\n' >&2
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
if ! "${ANDROID_BUILD_TOOLS}/aapt" version >/dev/null 2>&1; then
|
||||
if ! "${ANDROID_TOOL_EXEC_DIR}/aapt" version >/dev/null 2>&1; then
|
||||
printf ' bundled Android APK analyzer cannot execute\n' >&2
|
||||
status=1
|
||||
fi
|
||||
if [[ "${ABI_ANDROID_TOOLS_LIBC}" == java ]] &&
|
||||
! "${ANDROID_BUILD_TOOLS}/apkanalyzer" --help >/dev/null 2>&1; then
|
||||
! "${ANDROID_TOOL_EXEC_DIR}/apkanalyzer" --help >/dev/null 2>&1; then
|
||||
printf ' bundled Android apkanalyzer cannot execute with Java\n' >&2
|
||||
status=1
|
||||
fi
|
||||
if ! "${ANDROID_BUILD_TOOLS}/apksigner" version >/dev/null 2>&1; then
|
||||
if ! "${ANDROID_TOOL_EXEC_DIR}/apksigner" version >/dev/null 2>&1; then
|
||||
printf ' bundled Android apksigner cannot execute with host Java\n' >&2
|
||||
status=1
|
||||
fi
|
||||
@@ -461,12 +465,12 @@ CheckSystem() {
|
||||
if (( status != 0 )); then
|
||||
if [[ "${ABI_LIBC}" == glibc ]]; then
|
||||
printf '\nArch example:\n' >&2
|
||||
printf ' sudo pacman -S --needed vulkan-icd-loader libadwaita webkitgtk-6.0 jq unzip file binutils jre-openjdk-headless python-yaml python-requests python-capstone\n' >&2
|
||||
printf ' sudo pacman -S --needed vulkan-icd-loader libadwaita webkitgtk-6.0 jq unzip file binutils jre-openjdk-headless\n' >&2
|
||||
printf '\nVoid x86_64-glibc example:\n' >&2
|
||||
else
|
||||
printf '\nVoid x86_64-musl example:\n' >&2
|
||||
fi
|
||||
printf ' sudo xbps-install -S vulkan-loader libadwaita libwebkitgtk60 jq unzip file binutils openjdk17-jre python3-yaml python3-requests capstone-python3\n' >&2
|
||||
printf ' sudo xbps-install -S vulkan-loader libadwaita libwebkitgtk60 jq unzip file binutils openjdk17-jre\n' >&2
|
||||
printf 'Install the Vulkan ICD matching the GPU (for example vulkan-radeon, nvidia-utils, or vulkan-intel).\n' >&2
|
||||
return "${status}"
|
||||
fi
|
||||
@@ -520,9 +524,11 @@ export MOCKTAIL_UPDATE_HOST_ABI_REFERENCE="${METADATA_DIR}/roblox_host_abi_refer
|
||||
export MOCKTAIL_BOOTSTRAP_SOURCES_PATH="${METADATA_DIR}/roblox_bootstrap_sources.json"
|
||||
export MOCKTAIL_UPDATE_HELPER="${UPDATE_HELPER}"
|
||||
export MOCKTAIL_UPDATE_CANARY_BIN="${MAIN_BINARY}"
|
||||
export MOCKTAIL_UPDATE_SMOKE_SCRIPT="${RUNTIME_ROOT}/scripts/real_bringup_smoke.sh"
|
||||
export MOCKTAIL_BIN="${MAIN_BINARY}"
|
||||
export MOCKTAIL_PORTABLE_MODE="${ABI_MODE}"
|
||||
# AnyLinux relocates executables into its own bin directory. The Bionic
|
||||
# adapters stay in the original bundle and must be loaded by exact path.
|
||||
export MOCKTAIL_RUNTIME_LIBRARY_DIR="${BUNDLED_BIN_DIR}"
|
||||
|
||||
cd -- "${RUNTIME_ROOT}"
|
||||
exec "${MAIN_BINARY}" "$@"
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
|
||||
cmake,
|
||||
git,
|
||||
lld,
|
||||
ninja,
|
||||
pkg-config,
|
||||
vulkan-headers,
|
||||
makeWrapper,
|
||||
|
||||
capstone,
|
||||
curl,
|
||||
fontconfig,
|
||||
gamemode,
|
||||
glib,
|
||||
glib-networking,
|
||||
gtk4,
|
||||
hicolor-icon-theme,
|
||||
libadwaita,
|
||||
elfutils,
|
||||
libglvnd,
|
||||
libplacebo,
|
||||
libsoup_3,
|
||||
libutf8proc,
|
||||
libyaml,
|
||||
minizip,
|
||||
nlohmann_json,
|
||||
openssl,
|
||||
sdl3,
|
||||
sdl3-ttf,
|
||||
vulkan-loader,
|
||||
webkitgtk_6_0,
|
||||
zlib,
|
||||
}:
|
||||
let
|
||||
# submodules are not part of the flake source tree
|
||||
# so these two vendored dependencies are fetched from their pinned upstream revisions
|
||||
libjnivmSrc = fetchFromGitHub {
|
||||
owner = "ChristopherHX";
|
||||
repo = "libjnivm";
|
||||
rev = "f24b98c198fcc5c59a68d1efadd3f5791eb01c2e";
|
||||
hash = "sha256-K4+1Zo7AJIouIqnHn2ZE4GyrNrYOczmWYblLAnGYA5w=";
|
||||
};
|
||||
vulkanHeadersSrc = fetchFromGitHub {
|
||||
owner = "KhronosGroup";
|
||||
repo = "Vulkan-Headers";
|
||||
rev = "8d6039a455a7ecc7d2a592ff97f62db4e59b70bf";
|
||||
hash = "sha256-+llJlEdzFcmqGBAdKHjYhkwBuH19ZngavmF1TQgejsI=";
|
||||
};
|
||||
|
||||
src = ../..;
|
||||
|
||||
lines = lib.splitString "\n" (builtins.readFile (lib.path.append src "CMakeLists.txt"));
|
||||
|
||||
matches = map (builtins.match "[[:space:]]*VERSION[[:space:]]+([0-9][0-9.]*).*") lines;
|
||||
|
||||
match = lib.findFirst (m: m != null) null matches;
|
||||
|
||||
version =
|
||||
if match == null then throw "could not find VERSION in CMakeLists.txt" else builtins.elemAt match 0;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "mocktail";
|
||||
inherit version;
|
||||
inherit src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
cmake
|
||||
git
|
||||
lld
|
||||
ninja
|
||||
pkg-config
|
||||
vulkan-headers
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
capstone
|
||||
curl
|
||||
fontconfig
|
||||
# Loaded with dlmopen at runtime. Keeping it here also adds libgamemode.so.0
|
||||
# to the wrapped binaries' and development shell's LD_LIBRARY_PATH.
|
||||
gamemode
|
||||
glib
|
||||
glib-networking
|
||||
gtk4
|
||||
hicolor-icon-theme
|
||||
libadwaita
|
||||
elfutils
|
||||
libglvnd
|
||||
libplacebo
|
||||
libsoup_3
|
||||
libutf8proc
|
||||
libyaml
|
||||
minizip
|
||||
nlohmann_json
|
||||
openssl
|
||||
sdl3
|
||||
sdl3-ttf
|
||||
vulkan-loader
|
||||
webkitgtk_6_0
|
||||
zlib
|
||||
];
|
||||
|
||||
preConfigure = ''
|
||||
rm -rf third_party/libjnivm third_party/Vulkan-Headers
|
||||
cp -r --no-preserve=mode ${libjnivmSrc} third_party/libjnivm
|
||||
cp -r --no-preserve=mode ${vulkanHeadersSrc} third_party/Vulkan-Headers
|
||||
'';
|
||||
|
||||
# nixpkgs installs the header we need under include/libutf8proc
|
||||
NIX_CFLAGS_COMPILE = "-I${libutf8proc}/include/libutf8proc";
|
||||
|
||||
cmakeFlags = [
|
||||
"-DCMAKE_BUILD_TYPE=Release"
|
||||
"-DCMAKE_INSTALL_LIBDIR=lib"
|
||||
"-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=${placeholder "out"}/share/mocktail/metadata/roblox_compatibility.json"
|
||||
"-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=${placeholder "out"}/share/mocktail/metadata/roblox_signing_certificates.json"
|
||||
"-DBUILD_TESTING=OFF"
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
for binary in $out/bin/*; do
|
||||
wrapProgram "$binary" --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath finalAttrs.buildInputs} --prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules"
|
||||
done
|
||||
'';
|
||||
|
||||
fixupPhase = ''
|
||||
install -Dm644 ${finalAttrs.src}/LICENSE "$out/share/licenses/${finalAttrs.pname}/LICENSE"
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Android x86-64 Roblox compatibility runtime for Linux";
|
||||
homepage = "https://github.com/komaruworld/mocktail";
|
||||
license = licenses.asl20;
|
||||
platforms = platforms.unix;
|
||||
mainProgram = "mocktail";
|
||||
};
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Mocktail
|
||||
GenericName=Roblox Player
|
||||
Comment=Play Roblox on Linux
|
||||
Exec=mocktail %u
|
||||
Icon=space.bigrat.mocktail
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Report when the pinned Roblox payload metadata has fallen behind.
|
||||
|
||||
Two kinds of drift make a first launch fail, and neither one shows up until a
|
||||
user hits it:
|
||||
|
||||
* the compatibility catalog no longer lists the Roblox version the provider
|
||||
publishes, so every new install lands on a version Roblox has since retired;
|
||||
* the preferred supported profile has no pinned bootstrap source, so the
|
||||
pinned bootstrap path cannot produce the version the updater falls back to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
|
||||
PACKAGE = "com.roblox.client"
|
||||
ARCHITECTURE = "x86_64"
|
||||
|
||||
|
||||
class DriftError(RuntimeError):
|
||||
"""Raised when the pinned metadata cannot be inspected at all."""
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
try:
|
||||
encoded = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise DriftError(f"cannot read {path}") from error
|
||||
if len(encoded) > MAX_MANIFEST_BYTES:
|
||||
raise DriftError(f"{path} exceeds its size limit")
|
||||
try:
|
||||
document = json.loads(encoded.decode("utf-8"))
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise DriftError(f"cannot parse {path}") from error
|
||||
if not isinstance(document, dict) or document.get("schema_version") != 1:
|
||||
raise DriftError(f"{path} has an unsupported schema")
|
||||
return document
|
||||
|
||||
|
||||
def supported_profiles(catalog: dict) -> list[dict]:
|
||||
profiles = catalog.get("profiles")
|
||||
if not isinstance(profiles, list):
|
||||
raise DriftError("compatibility catalog has no profiles")
|
||||
return [
|
||||
profile
|
||||
for profile in profiles
|
||||
if isinstance(profile, dict)
|
||||
and profile.get("status") == "supported"
|
||||
and profile.get("default_allowed") is True
|
||||
and isinstance(profile.get("version_name"), str)
|
||||
and isinstance(profile.get("version_code"), int)
|
||||
]
|
||||
|
||||
|
||||
def preferred_profile(catalog: dict) -> dict:
|
||||
"""The profile the updater falls back to, matching the C++ coordinator."""
|
||||
profiles = supported_profiles(catalog)
|
||||
if not profiles:
|
||||
raise DriftError("compatibility catalog has no supported profile")
|
||||
return max(profiles, key=lambda profile: profile["version_code"])
|
||||
|
||||
|
||||
def pinned_versions(sources: dict) -> set[str]:
|
||||
entries = sources.get("sources")
|
||||
if not isinstance(entries, list):
|
||||
raise DriftError("bootstrap source manifest has no sources")
|
||||
return {
|
||||
entry["version_name"]
|
||||
for entry in entries
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("package") == PACKAGE
|
||||
and entry.get("abi") == ARCHITECTURE
|
||||
and isinstance(entry.get("version_name"), str)
|
||||
}
|
||||
|
||||
|
||||
def latest_published_version(provider: Path) -> tuple[str, int]:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(provider), "--check", "--package", PACKAGE,
|
||||
"--arch", ARCHITECTURE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise DriftError(
|
||||
"provider metadata is unavailable: "
|
||||
+ (completed.stderr.strip() or "no diagnostic")
|
||||
)
|
||||
try:
|
||||
reported = json.loads(completed.stdout)
|
||||
return str(reported["latest_version_name"]), int(
|
||||
reported["latest_version_code"]
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
raise DriftError("provider metadata is not understood") from error
|
||||
|
||||
|
||||
def findings(catalog: dict, sources: dict, latest: tuple[str, int] | None
|
||||
) -> list[str]:
|
||||
reported: list[str] = []
|
||||
preferred = preferred_profile(catalog)
|
||||
pinned = pinned_versions(sources)
|
||||
if preferred["version_name"] not in pinned:
|
||||
reported.append(
|
||||
f"The preferred supported profile {preferred['version_name']} "
|
||||
f"({preferred['version_code']}) has no pinned bootstrap source. "
|
||||
"The pinned bootstrap path cannot produce the version the updater "
|
||||
"falls back to."
|
||||
)
|
||||
if latest is not None:
|
||||
latest_name, latest_code = latest
|
||||
if latest_code > preferred["version_code"]:
|
||||
reported.append(
|
||||
f"Roblox {latest_name} ({latest_code}) is published, but the "
|
||||
f"newest supported profile is {preferred['version_name']} "
|
||||
f"({preferred['version_code']}). New installs land on a "
|
||||
"version Roblox may already have retired."
|
||||
)
|
||||
return reported
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--catalog", type=Path,
|
||||
default=root / "config/roblox_compatibility.json")
|
||||
parser.add_argument(
|
||||
"--sources", type=Path,
|
||||
default=root / "config/roblox_bootstrap_sources.json")
|
||||
parser.add_argument(
|
||||
"--provider", type=Path,
|
||||
default=root / "scripts/apk_providers/direct_apkpure.py")
|
||||
parser.add_argument(
|
||||
"--check-latest", action="store_true",
|
||||
help="also ask the provider which version is published")
|
||||
arguments = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
catalog = _load(arguments.catalog)
|
||||
sources = _load(arguments.sources)
|
||||
latest = (
|
||||
latest_published_version(arguments.provider)
|
||||
if arguments.check_latest
|
||||
else None
|
||||
)
|
||||
reported = findings(catalog, sources, latest)
|
||||
except DriftError as error:
|
||||
print(f"payload source drift: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not reported:
|
||||
print("Pinned Roblox payload metadata is current.")
|
||||
return 0
|
||||
for finding in reported:
|
||||
print(f"- {finding}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -67,6 +67,7 @@ ELF_VERSION_CURRENT = 1
|
||||
ELF_TYPE_SHARED_OBJECT = 3
|
||||
ELF_MACHINE_X86_64 = 62
|
||||
PT_LOAD = 1
|
||||
PT_GNU_RELRO = 0x6474E552
|
||||
PF_EXECUTE = 1
|
||||
PF_WRITE = 2
|
||||
PF_READ = 4
|
||||
@@ -159,6 +160,7 @@ class SignatureSpec:
|
||||
instruction_count: int
|
||||
allow_registry_object_size_change: bool = False
|
||||
minimum_anchor_bytes: int = 6
|
||||
registry_size_indices: tuple[int, int] = (8, 12)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -217,6 +219,7 @@ class ElfImage:
|
||||
self.sections: dict[str, Section] = {}
|
||||
self.sections_by_index: tuple[Section, ...] = ()
|
||||
self.segments: tuple[Segment, ...] = ()
|
||||
self.relro_ranges: tuple[tuple[int, int], ...] = ()
|
||||
self.build_id = ""
|
||||
|
||||
def __enter__(self) -> "ElfImage":
|
||||
@@ -300,10 +303,15 @@ class ElfImage:
|
||||
)
|
||||
|
||||
segments = []
|
||||
relro_ranges = []
|
||||
for index in range(program_count):
|
||||
values = PROGRAM_HEADER.unpack_from(
|
||||
self.data, program_offset + index * program_entry_size
|
||||
)
|
||||
if values[0] == PT_GNU_RELRO:
|
||||
if values[6] <= 0:
|
||||
raise AnalyzerError("ELF PT_GNU_RELRO range is empty")
|
||||
relro_ranges.append((values[3], values[6]))
|
||||
if values[0] != PT_LOAD:
|
||||
continue
|
||||
segment = Segment(
|
||||
@@ -320,6 +328,7 @@ class ElfImage:
|
||||
if not segments:
|
||||
raise AnalyzerError("ELF has no PT_LOAD segments")
|
||||
self.segments = tuple(segments)
|
||||
self.relro_ranges = tuple(relro_ranges)
|
||||
|
||||
raw_sections = [
|
||||
SECTION_HEADER.unpack_from(
|
||||
@@ -455,6 +464,17 @@ class ElfImage:
|
||||
):
|
||||
raise AnalyzerError(f"RVA 0x{rva:x} is not in a non-executable RW segment")
|
||||
|
||||
def require_relro_rva(self, rva: int, size: int = 1) -> None:
|
||||
matches = [
|
||||
(begin, length)
|
||||
for begin, length in self.relro_ranges
|
||||
if size >= 0 and begin <= rva and rva + size <= begin + length
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise AnalyzerError(
|
||||
f"RVA 0x{rva:x} is not in a unique PT_GNU_RELRO range"
|
||||
)
|
||||
|
||||
def rva_to_offset(self, rva: int, size: int = 1) -> int:
|
||||
matches = [
|
||||
segment
|
||||
@@ -1058,23 +1078,30 @@ def semantic_shape(
|
||||
def validate_semantic_match(
|
||||
reference: Sequence[Any],
|
||||
candidate: Sequence[Any],
|
||||
allow_registry_object_size_change: bool,
|
||||
spec: SignatureSpec,
|
||||
) -> None:
|
||||
if len(reference) != len(candidate):
|
||||
raise AnalyzerError("normalized signature instruction count changed")
|
||||
for index, (reference_instruction, candidate_instruction) in enumerate(
|
||||
zip(reference, candidate)
|
||||
):
|
||||
wildcard_size = allow_registry_object_size_change and index in (8, 12)
|
||||
wildcard_size = (
|
||||
spec.allow_registry_object_size_change
|
||||
and index in spec.registry_size_indices
|
||||
)
|
||||
if semantic_shape(reference_instruction, wildcard_size) != semantic_shape(
|
||||
candidate_instruction, wildcard_size
|
||||
):
|
||||
raise AnalyzerError(
|
||||
f"normalized instruction semantics changed at signature index {index}"
|
||||
)
|
||||
if allow_registry_object_size_change:
|
||||
if spec.allow_registry_object_size_change:
|
||||
sizes = []
|
||||
for index in (8, 12):
|
||||
for index in spec.registry_size_indices:
|
||||
if index >= len(candidate):
|
||||
raise AnalyzerError(
|
||||
"registry initializer size index is outside its signature"
|
||||
)
|
||||
operands = candidate[index].operands
|
||||
if len(operands) != 2 or operands[1].type != capstone_x86.X86_OP_IMM:
|
||||
raise AnalyzerError("registry initializer size operands changed")
|
||||
@@ -1154,7 +1181,7 @@ def find_signature_matches(
|
||||
validate_semantic_match(
|
||||
reference_instructions,
|
||||
candidate_instructions,
|
||||
spec.allow_registry_object_size_change,
|
||||
spec,
|
||||
)
|
||||
except AnalyzerError as error:
|
||||
semantic_errors.append(error)
|
||||
@@ -1180,6 +1207,360 @@ def find_signature_match(
|
||||
return matches[0]
|
||||
|
||||
|
||||
def unique_semantic_signature_match(
|
||||
reference: ElfImage, candidate: ElfImage, spec: SignatureSpec
|
||||
) -> SignatureMatch:
|
||||
matches = find_signature_matches(
|
||||
reference, candidate, spec, allow_multiple_candidates=True
|
||||
)
|
||||
if len(matches) != 1:
|
||||
raise AnalyzerError(
|
||||
f"signature {spec.name} matched {len(matches)} semantic candidate locations"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def relative_relocation_map(image: ElfImage) -> dict[int, int]:
|
||||
result = {}
|
||||
for relocation in decode_aps2_relocations(
|
||||
image.section_bytes(image.sections[".rela.dyn"])
|
||||
):
|
||||
if (
|
||||
relocation.info & 0xFFFFFFFF != R_X86_64_RELATIVE
|
||||
or relocation.info >> 32 != 0
|
||||
):
|
||||
continue
|
||||
if relocation.offset in result:
|
||||
raise AnalyzerError("ELF contains duplicate relative relocations")
|
||||
result[relocation.offset] = relocation.addend
|
||||
return result
|
||||
|
||||
|
||||
def has_fullscreen_setter_contract(image: ElfImage, rva: int) -> bool:
|
||||
size = 96
|
||||
try:
|
||||
image.require_code_rva(rva, size)
|
||||
offset = image.rva_to_offset(rva, size)
|
||||
code = image.bytes_at(offset, size, "fullscreen setter contract")
|
||||
except AnalyzerError:
|
||||
return False
|
||||
fields = (b"\x59\x01\x00\x00", b"\x61\x01\x00\x00")
|
||||
return (
|
||||
code.startswith(b"\x55\x48\x89\xe5")
|
||||
and b"\x89\xf3" in code[:24]
|
||||
and any(
|
||||
b"\x38\x98" + field in code and b"\x88\x98" + field in code
|
||||
for field in fields
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def has_fmod_string_constructor_contract(image: ElfImage, rva: int) -> bool:
|
||||
expected = bytes.fromhex(
|
||||
"55 48 89 e5 41 57 41 56 41 54 53 48 89 d3 49 89 "
|
||||
"f6 49 89 ff 48 83 fa 16"
|
||||
)
|
||||
try:
|
||||
image.require_code_rva(rva, len(expected))
|
||||
offset = image.rva_to_offset(rva, len(expected))
|
||||
return (
|
||||
image.bytes_at(offset, len(expected), "FMOD string contract")
|
||||
== expected
|
||||
)
|
||||
except AnalyzerError:
|
||||
return False
|
||||
|
||||
|
||||
def load_reference_runtime_profile(
|
||||
paths: Sequence[Path], reference_build_id: str
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for path in paths:
|
||||
document = read_json_object(path, "reference compatibility manifest")
|
||||
profiles = document.get("profiles")
|
||||
if document.get("schema_version") != 1 or not isinstance(profiles, list):
|
||||
raise AnalyzerError(
|
||||
"reference compatibility manifest schema is unsupported"
|
||||
)
|
||||
for profile in profiles:
|
||||
if (
|
||||
not isinstance(profile, dict)
|
||||
or profile.get("elf_build_id") != reference_build_id
|
||||
):
|
||||
continue
|
||||
discovered: dict[str, Any] = {}
|
||||
if "user_game_settings_fullscreen_setter_rva" in profile:
|
||||
discovered["user_game_settings_fullscreen_setter_rva"] = parse_rva(
|
||||
profile["user_game_settings_fullscreen_setter_rva"],
|
||||
"reference fullscreen setter",
|
||||
)
|
||||
if "fmod_output_device_bridge" in profile:
|
||||
bridge = profile["fmod_output_device_bridge"]
|
||||
fields = {
|
||||
"vtable_rva",
|
||||
"string_constructor_rva",
|
||||
"count_method_rva",
|
||||
"info_method_rva",
|
||||
"current_method_rva",
|
||||
"select_method_rva",
|
||||
}
|
||||
if (
|
||||
not isinstance(bridge, dict)
|
||||
or set(bridge) != fields
|
||||
or profile.get("allow_host_abi_bridges") is not True
|
||||
):
|
||||
raise AnalyzerError(
|
||||
"reference FMOD output-device profile is incomplete"
|
||||
)
|
||||
discovered["fmod_output_device_bridge"] = {
|
||||
field: parse_rva(value, f"reference FMOD {field}")
|
||||
for field, value in bridge.items()
|
||||
}
|
||||
for field, value in discovered.items():
|
||||
if field in result and result[field] != value:
|
||||
raise AnalyzerError(
|
||||
f"reference compatibility manifests disagree on {field}"
|
||||
)
|
||||
result[field] = value
|
||||
return result
|
||||
|
||||
|
||||
def derive_fmod_output_device_bridge(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
source: dict[str, int],
|
||||
) -> dict[str, str]:
|
||||
indexes = {
|
||||
"count_method_rva": 5,
|
||||
"info_method_rva": 6,
|
||||
"current_method_rva": 7,
|
||||
"select_method_rva": 17,
|
||||
}
|
||||
source_vtable = source["vtable_rva"]
|
||||
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
|
||||
reference_relocations = relative_relocation_map(reference)
|
||||
for field, index in indexes.items():
|
||||
if reference_relocations.get(source_vtable + index * 8) != source[field]:
|
||||
raise AnalyzerError("reference FMOD vtable does not match its methods")
|
||||
reference.require_code_rva(source[field])
|
||||
if not has_fmod_string_constructor_contract(
|
||||
reference, source["string_constructor_rva"]
|
||||
):
|
||||
raise AnalyzerError("reference FMOD string constructor contract changed")
|
||||
|
||||
string_constructor = unique_semantic_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"fmod-string-constructor",
|
||||
source["string_constructor_rva"],
|
||||
32,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
).rva
|
||||
info_method = unique_semantic_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"fmod-output-info",
|
||||
source["info_method_rva"],
|
||||
18,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
).rva
|
||||
select_method = unique_semantic_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"fmod-output-select",
|
||||
source["select_method_rva"],
|
||||
18,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
).rva
|
||||
count_candidates = {
|
||||
match.rva
|
||||
for match in find_signature_matches(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"fmod-output-count",
|
||||
source["count_method_rva"],
|
||||
24,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
allow_multiple_candidates=True,
|
||||
)
|
||||
}
|
||||
current_candidates = {
|
||||
match.rva
|
||||
for match in find_signature_matches(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"fmod-output-current",
|
||||
source["current_method_rva"],
|
||||
24,
|
||||
minimum_anchor_bytes=3,
|
||||
),
|
||||
allow_multiple_candidates=True,
|
||||
)
|
||||
}
|
||||
if not count_candidates or not current_candidates:
|
||||
raise AnalyzerError("FMOD count/current method signatures have no candidates")
|
||||
|
||||
relocations = relative_relocation_map(candidate)
|
||||
vtables = []
|
||||
for offset, addend in relocations.items():
|
||||
if addend != info_method or offset < indexes["info_method_rva"] * 8:
|
||||
continue
|
||||
vtable = offset - indexes["info_method_rva"] * 8
|
||||
count_method = relocations.get(vtable + indexes["count_method_rva"] * 8)
|
||||
current_method = relocations.get(
|
||||
vtable + indexes["current_method_rva"] * 8
|
||||
)
|
||||
if (
|
||||
vtable % 8 == 0
|
||||
and count_method in count_candidates
|
||||
and current_method in current_candidates
|
||||
and count_method != current_method
|
||||
and relocations.get(vtable + indexes["select_method_rva"] * 8)
|
||||
== select_method
|
||||
):
|
||||
try:
|
||||
candidate.require_relro_rva(
|
||||
vtable, (max(indexes.values()) + 1) * 8
|
||||
)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
vtables.append((vtable, count_method, current_method))
|
||||
if len(vtables) != 1:
|
||||
raise AnalyzerError(
|
||||
f"FMOD output vtable matched {len(vtables)} candidate locations"
|
||||
)
|
||||
vtable, count_method, current_method = vtables[0]
|
||||
if not has_fmod_string_constructor_contract(candidate, string_constructor):
|
||||
raise AnalyzerError("candidate FMOD string constructor contract changed")
|
||||
return {
|
||||
"vtable_rva": format_rva(vtable),
|
||||
"string_constructor_rva": format_rva(string_constructor),
|
||||
"count_method_rva": format_rva(count_method),
|
||||
"info_method_rva": format_rva(info_method),
|
||||
"current_method_rva": format_rva(current_method),
|
||||
"select_method_rva": format_rva(select_method),
|
||||
}
|
||||
|
||||
|
||||
def derive_runtime_compatibility(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
manifests: Sequence[Path],
|
||||
) -> dict[str, Any]:
|
||||
source = load_reference_runtime_profile(manifests, reference.build_id)
|
||||
result: dict[str, Any] = {}
|
||||
fullscreen = source.get("user_game_settings_fullscreen_setter_rva")
|
||||
if fullscreen is not None:
|
||||
if not has_fullscreen_setter_contract(reference, fullscreen):
|
||||
raise AnalyzerError("reference fullscreen setter contract changed")
|
||||
setter = unique_semantic_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"fullscreen-setter", fullscreen, 32, minimum_anchor_bytes=3
|
||||
),
|
||||
).rva
|
||||
if not has_fullscreen_setter_contract(candidate, setter):
|
||||
raise AnalyzerError("candidate fullscreen setter contract changed")
|
||||
result["user_game_settings_fullscreen_setter_rva"] = format_rva(setter)
|
||||
fmod = source.get("fmod_output_device_bridge")
|
||||
if fmod is not None:
|
||||
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
|
||||
reference, candidate, fmod
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def find_registry_function_entry(
|
||||
candidate: ElfImage,
|
||||
body_rva: int,
|
||||
reference_prologue: Sequence[Any],
|
||||
) -> int:
|
||||
prologue_instruction_count = 8
|
||||
maximum_prologue_bytes = 64
|
||||
if (
|
||||
len(reference_prologue) != prologue_instruction_count
|
||||
or body_rva < maximum_prologue_bytes
|
||||
):
|
||||
raise AnalyzerError("reference registry prologue shape is invalid")
|
||||
entries = []
|
||||
for rva in range(body_rva - maximum_prologue_bytes, body_rva):
|
||||
try:
|
||||
instructions = disassemble(candidate, rva, prologue_instruction_count)
|
||||
except AnalyzerError:
|
||||
continue
|
||||
if instructions[-1].address + instructions[-1].size != body_rva:
|
||||
continue
|
||||
if any(
|
||||
semantic_shape(reference_instruction, index == 7)
|
||||
!= semantic_shape(candidate_instruction, index == 7)
|
||||
for index, (reference_instruction, candidate_instruction) in enumerate(
|
||||
zip(reference_prologue, instructions)
|
||||
)
|
||||
):
|
||||
continue
|
||||
frame = instructions[-1]
|
||||
operands = frame.operands
|
||||
if (
|
||||
frame.mnemonic == "sub"
|
||||
and len(operands) == 2
|
||||
and operands[0].type == capstone_x86.X86_OP_REG
|
||||
and operands[0].reg == capstone_x86.X86_REG_RSP
|
||||
and operands[1].type == capstone_x86.X86_OP_IMM
|
||||
and 0x20 <= operands[1].imm <= 0x400
|
||||
and operands[1].imm % 8 == 0
|
||||
):
|
||||
entries.append(rva)
|
||||
if len(entries) != 1:
|
||||
raise AnalyzerError(
|
||||
"registry initializer body has no unique bounded prologue"
|
||||
)
|
||||
return entries[0]
|
||||
|
||||
|
||||
def find_registry_signature_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
) -> SignatureMatch:
|
||||
body_instruction_index = 8
|
||||
body_instruction_count = 11
|
||||
reference_full = disassemble(
|
||||
reference, spec.reference_rva, spec.instruction_count
|
||||
)
|
||||
if len(reference_full) < body_instruction_index + body_instruction_count:
|
||||
raise AnalyzerError("reference registry signature is incomplete")
|
||||
body_spec = SignatureSpec(
|
||||
"registry-initializer-body",
|
||||
reference_full[body_instruction_index].address,
|
||||
body_instruction_count,
|
||||
True,
|
||||
spec.minimum_anchor_bytes,
|
||||
(0, 4),
|
||||
)
|
||||
body_match = find_signature_match(reference, candidate, body_spec)
|
||||
candidate_entry = find_registry_function_entry(
|
||||
candidate,
|
||||
body_match.rva,
|
||||
reference_full[:body_instruction_index],
|
||||
)
|
||||
candidate_full = disassemble(
|
||||
candidate, candidate_entry, spec.instruction_count
|
||||
)
|
||||
if candidate_full[body_instruction_index].address != body_match.rva:
|
||||
raise AnalyzerError("candidate registry signature is incomplete")
|
||||
return SignatureMatch(candidate_entry, reference_full, candidate_full)
|
||||
|
||||
|
||||
def direct_call_target(instruction: Any, description: str) -> int:
|
||||
if (
|
||||
instruction.mnemonic != "call"
|
||||
@@ -1190,6 +1571,51 @@ def direct_call_target(instruction: Any, description: str) -> int:
|
||||
return instruction.operands[0].imm
|
||||
|
||||
|
||||
def direct_jump_target(instruction: Any, description: str) -> int:
|
||||
if (
|
||||
instruction.mnemonic != "jmp"
|
||||
or len(instruction.operands) != 1
|
||||
or instruction.operands[0].type != capstone_x86.X86_OP_IMM
|
||||
):
|
||||
raise AnalyzerError(f"{description} is not one direct jump")
|
||||
return instruction.operands[0].imm
|
||||
|
||||
|
||||
def find_constructor_boundary_match(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
reference_entry: int,
|
||||
candidate_entry: int,
|
||||
) -> SignatureMatch:
|
||||
if reference_entry != spec.reference_rva:
|
||||
raise AnalyzerError(
|
||||
"reference constructor boundary moved outside init-array index 4"
|
||||
)
|
||||
reference_wrapper = disassemble(reference, reference_entry, 1)
|
||||
reference_target = direct_jump_target(
|
||||
reference_wrapper[0], "reference constructor boundary"
|
||||
)
|
||||
target_match = find_signature_match(
|
||||
reference,
|
||||
candidate,
|
||||
SignatureSpec(
|
||||
"constructor-4-target",
|
||||
reference_target,
|
||||
spec.instruction_count,
|
||||
),
|
||||
)
|
||||
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
|
||||
candidate_target = direct_jump_target(
|
||||
candidate_wrapper[0], "candidate constructor boundary"
|
||||
)
|
||||
if candidate_target != target_match.rva:
|
||||
raise AnalyzerError(
|
||||
"constructor index 4 no longer targets its verified boundary"
|
||||
)
|
||||
return SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
|
||||
|
||||
|
||||
def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||
targets = []
|
||||
for operand in instruction.operands:
|
||||
@@ -1417,6 +1843,14 @@ def derive_profile(
|
||||
|
||||
reference_profile = reference_sidecar["profile"]
|
||||
anchors = reference_sidecar["derivation_anchors"]
|
||||
reference_init = reference.init_array_relocations()
|
||||
candidate_init = candidate.init_array_relocations()
|
||||
if len(reference_init) != reference_profile["init_array_count"]:
|
||||
raise AnalyzerError(
|
||||
"reference sidecar init-array count differs from ELF metadata"
|
||||
)
|
||||
if len(candidate_init) < 8:
|
||||
raise AnalyzerError("candidate init-array is unexpectedly short")
|
||||
matches = {}
|
||||
allocate_matches = ()
|
||||
for spec in create_signature_specs(reference_profile, anchors):
|
||||
@@ -1424,6 +1858,18 @@ def derive_profile(
|
||||
allocate_matches = find_signature_matches(
|
||||
reference, candidate, spec, allow_multiple_candidates=True
|
||||
)
|
||||
elif spec.name == "registry-initializer":
|
||||
matches[spec.name] = find_registry_signature_match(
|
||||
reference, candidate, spec
|
||||
)
|
||||
elif spec.name == "constructor-4":
|
||||
matches[spec.name] = find_constructor_boundary_match(
|
||||
reference,
|
||||
candidate,
|
||||
spec,
|
||||
reference_init[4],
|
||||
candidate_init[4],
|
||||
)
|
||||
else:
|
||||
matches[spec.name] = find_signature_match(reference, candidate, spec)
|
||||
|
||||
@@ -1458,14 +1904,6 @@ def derive_profile(
|
||||
"registry allocator call"
|
||||
)
|
||||
matches["allocate"] = selected_allocate[0]
|
||||
reference_init = reference.init_array_relocations()
|
||||
candidate_init = candidate.init_array_relocations()
|
||||
if len(reference_init) != reference_profile["init_array_count"]:
|
||||
raise AnalyzerError(
|
||||
"reference sidecar init-array count differs from ELF metadata"
|
||||
)
|
||||
if len(candidate_init) < 8:
|
||||
raise AnalyzerError("candidate init-array is unexpectedly short")
|
||||
for index in (2, 3, 4):
|
||||
match = matches[f"constructor-{index}"]
|
||||
if (
|
||||
@@ -1635,6 +2073,7 @@ def output_documents(
|
||||
reference_sidecar: dict[str, Any],
|
||||
profile: dict[str, Any],
|
||||
anchors: dict[str, Any],
|
||||
runtime_compatibility: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
version_code = metadata["version_code"]
|
||||
payload_id = f"{version_code}-{candidate.build_id}"
|
||||
@@ -1651,25 +2090,26 @@ def output_documents(
|
||||
"profile": profile,
|
||||
"derivation_anchors": anchors,
|
||||
}
|
||||
compatibility_profile = {
|
||||
"version_name": metadata["version_name"],
|
||||
"version_code": version_code,
|
||||
"elf_build_id": candidate.build_id,
|
||||
"status": "experimental",
|
||||
"default_allowed": True,
|
||||
"allow_legacy_binary_patches": False,
|
||||
"allow_host_abi_bridges": True,
|
||||
"allow_host_constructor_replay": True,
|
||||
"reason": (
|
||||
"Machine-derived exact-Build-ID profile for isolated "
|
||||
"probation only; normal activation requires two "
|
||||
"successful no-recovery Tier C canaries."
|
||||
),
|
||||
}
|
||||
if runtime_compatibility:
|
||||
compatibility_profile.update(runtime_compatibility)
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"version_name": metadata["version_name"],
|
||||
"version_code": version_code,
|
||||
"elf_build_id": candidate.build_id,
|
||||
"status": "experimental",
|
||||
"default_allowed": True,
|
||||
"allow_legacy_binary_patches": False,
|
||||
"allow_host_abi_bridges": True,
|
||||
"allow_host_constructor_replay": True,
|
||||
"reason": (
|
||||
"Machine-derived exact-Build-ID profile for isolated "
|
||||
"probation only; it is not supported until the updater "
|
||||
"records a successful no-recovery Tier C canary."
|
||||
),
|
||||
}
|
||||
],
|
||||
"profiles": [compatibility_profile],
|
||||
}
|
||||
return sidecar, manifest
|
||||
|
||||
@@ -1720,8 +2160,18 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
|
||||
with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate:
|
||||
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
|
||||
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
|
||||
runtime_compatibility = derive_runtime_compatibility(
|
||||
reference,
|
||||
candidate,
|
||||
tuple(Path(path) for path in arguments.reference_compatibility),
|
||||
)
|
||||
return output_documents(
|
||||
candidate, metadata, reference_sidecar, profile, anchors
|
||||
candidate,
|
||||
metadata,
|
||||
reference_sidecar,
|
||||
profile,
|
||||
anchors,
|
||||
runtime_compatibility,
|
||||
)
|
||||
|
||||
|
||||
@@ -1731,6 +2181,13 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument("--reference-lib", required=True, metavar="REF")
|
||||
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
|
||||
parser.add_argument(
|
||||
"--reference-compatibility",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="MANIFEST",
|
||||
help="compatibility manifest containing exact reference runtime anchors",
|
||||
)
|
||||
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
|
||||
parser.add_argument("--payload-metadata", required=True, metavar="META")
|
||||
parser.add_argument("--output", required=True, metavar="PROFILE")
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# The reduced packages are intentionally limited to the disposable builder.
|
||||
# Never replace a developer's desktop / graphics packages on the host.
|
||||
[[ -f /.dockerenv || -f /run/.containerenv ]] || {
|
||||
printf 'AnyLinux dependency installation requires a disposable container\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$(id -u)" == 0 ]] || {
|
||||
printf 'AnyLinux dependency installation requires container root\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.sha256"
|
||||
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
|
||||
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
|
||||
readonly BASE_URL=https://github.com/pkgforge-dev/archlinux-pkgs-debloated/releases/download/continuous
|
||||
declare -a packages=()
|
||||
while read -r digest filename remainder; do
|
||||
[[ -n "${digest}" && "${digest}" != \#* ]] || continue
|
||||
[[ "${digest}" =~ ^[0-9a-f]{64}$ &&
|
||||
"${filename}" =~ ^[a-z0-9-]+-x86_64\.pkg\.tar\.zst$ &&
|
||||
-z "${remainder}" ]] || {
|
||||
printf 'Invalid AnyLinux dependency manifest entry\n' >&2
|
||||
exit 1
|
||||
}
|
||||
curl --fail --location --retry 3 --output "${DOWNLOADS}/${filename}" \
|
||||
"${BASE_URL}/${filename}"
|
||||
printf '%s %s\n' "${digest}" "${DOWNLOADS}/${filename}" |
|
||||
sha256sum --check --strict
|
||||
packages+=("${DOWNLOADS}/${filename}")
|
||||
done <"${MANIFEST}"
|
||||
(( ${#packages[@]} > 0 ))
|
||||
pacman -U --noconfirm --needed "${packages[@]}"
|
||||
@@ -109,9 +109,6 @@ unzip
|
||||
file
|
||||
binutils
|
||||
jre-openjdk-headless
|
||||
python-yaml
|
||||
python-requests
|
||||
python-capstone
|
||||
sdl3
|
||||
sdl3_ttf
|
||||
libutf8proc
|
||||
@@ -137,9 +134,6 @@ unzip
|
||||
file
|
||||
binutils
|
||||
openjdk17-jre
|
||||
python3-yaml
|
||||
python3-requests
|
||||
capstone-python3
|
||||
SDL3
|
||||
SDL3_ttf
|
||||
libutf8proc
|
||||
@@ -168,9 +162,6 @@ unzip
|
||||
file
|
||||
binutils
|
||||
openjdk17-jre-headless
|
||||
py3-yaml
|
||||
py3-requests
|
||||
py3-capstone
|
||||
sdl3
|
||||
sdl3_ttf
|
||||
utf8proc
|
||||
@@ -201,17 +192,6 @@ PackageInstalled() {
|
||||
binutils) command -v readelf >/dev/null 2>&1 && return 0 ;;
|
||||
jq|unzip|file) command -v "$package" >/dev/null 2>&1 && return 0 ;;
|
||||
util-linux) command -v flock >/dev/null 2>&1 && return 0 ;;
|
||||
python-yaml|python3-yaml|py3-yaml)
|
||||
python3 -c 'import yaml' >/dev/null 2>&1 && return 0
|
||||
;;
|
||||
python-requests|python3-requests|py3-requests)
|
||||
python3 -c 'import requests' >/dev/null 2>&1 && return 0
|
||||
;;
|
||||
python-capstone|capstone-python3|py3-capstone)
|
||||
python3 -c 'import capstone; raise SystemExit(capstone.cs_version()[0] != 5)' \
|
||||
>/dev/null 2>&1
|
||||
return $?
|
||||
;;
|
||||
esac
|
||||
case $DISTRO in
|
||||
arch) pacman -Qq "$package" >/dev/null 2>&1 ;;
|
||||
|
||||
Executable
+260
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -Eeuo pipefail
|
||||
umask 022
|
||||
|
||||
ANYLINUX_PROJECT_ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ANYLINUX_BUILD_DIR="${ANYLINUX_PROJECT_ROOT}/build"
|
||||
ANYLINUX_OUTPUT=""
|
||||
ANYLINUX_WORK=""
|
||||
ANYLINUX_FREEBSD_HELPER=""
|
||||
ANYLINUX_SYSTEM_INSTALL=false
|
||||
ANYLINUX_DEPLOY_INSTALLED=false
|
||||
ANYLINUX_TOOL="${MOCKTAIL_ANYLINUX_PACKAGER:-quick-sharun}"
|
||||
ANYLINUX_APPIMAGETOOL="${MOCKTAIL_ANYLINUX_APPIMAGETOOL:-appimagetool}"
|
||||
|
||||
AnyLinuxDie() {
|
||||
printf '[anylinux] error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
AnyLinuxCleanup() {
|
||||
[[ -z "${ANYLINUX_WORK}" ]] || rm -rf -- "${ANYLINUX_WORK}"
|
||||
}
|
||||
|
||||
AnyLinuxUsage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/package_anylinux.sh --build-dir DIR --appimage FILE [OPTIONS]
|
||||
|
||||
Install the native application to /usr, let quick-sharun deploy its complete
|
||||
runtime, then run quick-sharun --make-appimage. No portable bundle is embedded.
|
||||
|
||||
By default /usr is a private, read-only bubblewrap overlay of a DESTDIR install;
|
||||
the host installation is never modified. Requires an Arch Linux build host.
|
||||
--system-install Install directly to /usr in a disposable build container.
|
||||
Refused outside Docker/Podman containers. Used by CI.
|
||||
--deploy-installed Internal: package the already prepared /usr installation.
|
||||
|
||||
MOCKTAIL_ANYLINUX_PACKAGER and MOCKTAIL_ANYLINUX_APPIMAGETOOL select the tools.
|
||||
MOCKTAIL_ANYLINUX_SHARUN and MOCKTAIL_ANYLINUX_C_SOURCE select verified local
|
||||
upstream inputs; quick-sharun itself installs/builds them into the AppDir.
|
||||
EOF
|
||||
}
|
||||
|
||||
AnyLinuxParseArguments() {
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--build-dir|--appimage)
|
||||
(( $# >= 2 )) || AnyLinuxDie "$1 requires a path"
|
||||
if [[ "$1" == --build-dir ]]; then
|
||||
ANYLINUX_BUILD_DIR="$2"
|
||||
else
|
||||
ANYLINUX_OUTPUT="$2"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--system-install) ANYLINUX_SYSTEM_INSTALL=true; shift ;;
|
||||
--deploy-installed) ANYLINUX_DEPLOY_INSTALLED=true; shift ;;
|
||||
-h|--help) AnyLinuxUsage; exit 0 ;;
|
||||
*) AnyLinuxDie "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "${ANYLINUX_OUTPUT}" ]] || AnyLinuxDie "--appimage is required"
|
||||
[[ "${ANYLINUX_OUTPUT}" != *$'\n'* &&
|
||||
"${ANYLINUX_OUTPUT}" != *$'\r'* ]] || AnyLinuxDie "unsafe output path"
|
||||
[[ ! -L "${ANYLINUX_OUTPUT}" ]] || AnyLinuxDie "refusing symlink output"
|
||||
ANYLINUX_OUTPUT="$(realpath -m -- "${ANYLINUX_OUTPUT}")"
|
||||
[[ "${ANYLINUX_OUTPUT}" == *.AppImage && ! -d "${ANYLINUX_OUTPUT}" ]] ||
|
||||
AnyLinuxDie "output must be an AppImage file"
|
||||
}
|
||||
|
||||
AnyLinuxVerifyInstalled() {
|
||||
local entry
|
||||
for entry in /usr/bin/mocktail \
|
||||
/usr/lib/mocktail/mocktail_updater \
|
||||
/usr/lib/mocktail/mocktail_failure_dialog \
|
||||
/usr/lib/mocktail/mocktail_webview_helper; do
|
||||
[[ -x "${entry}" ]] || AnyLinuxDie "missing installed executable: ${entry}"
|
||||
done
|
||||
for entry in libEGL.so libvulkan.so libmediandk.so; do
|
||||
[[ -r "/usr/lib/mocktail/${entry}" ]] ||
|
||||
AnyLinuxDie "missing installed Android adapter: ${entry}"
|
||||
done
|
||||
for entry in roblox_compatibility.json roblox_bootstrap_sources.json \
|
||||
roblox_host_abi_reference.json roblox_signing_certificates.json; do
|
||||
[[ -r "/usr/share/mocktail/metadata/${entry}" ]] ||
|
||||
AnyLinuxDie "missing installed metadata: ${entry}"
|
||||
done
|
||||
LC_ALL=C readelf -l /usr/bin/mocktail | grep -q 'ld-linux.*so' ||
|
||||
AnyLinuxDie "AnyLinux requires a glibc runtime"
|
||||
if [[ -f /usr/share/mocktail/helpers/mocktail_freebsd_socket_helper ]]; then
|
||||
ANYLINUX_FREEBSD_HELPER=/usr/share/mocktail/helpers/mocktail_freebsd_socket_helper
|
||||
fi
|
||||
}
|
||||
|
||||
AnyLinuxVerifyAppDir() {
|
||||
local app_dir="$1" entry
|
||||
[[ ! -e "${app_dir}/usr" ]] || AnyLinuxDie "unexpected usr prefix in AppDir"
|
||||
[[ ! -e "${app_dir}/share/mocktail-bundle" ]] ||
|
||||
AnyLinuxDie "a portable bundle was embedded in AppDir"
|
||||
for entry in mocktail mocktail_updater mocktail_failure_dialog \
|
||||
mocktail_webview_helper; do
|
||||
[[ -x "${app_dir}/bin/${entry}" ]] ||
|
||||
AnyLinuxDie "quick-sharun did not deploy ${entry}"
|
||||
done
|
||||
for entry in WebKitNetworkProcess WebKitWebProcess WebKitGPUProcess; do
|
||||
[[ -x "${app_dir}/lib/webkitgtk-6.0/${entry}" ]] ||
|
||||
AnyLinuxDie "quick-sharun did not preserve the WebKit process layout: ${entry}"
|
||||
done
|
||||
[[ -r "${app_dir}/lib/webkitgtk-6.0/injected-bundle/libwebkitgtkinjectedbundle.so" ]] ||
|
||||
AnyLinuxDie "missing injected WebKit bundle"
|
||||
[[ -f "${app_dir}/AppRun.sh" && -f "${app_dir}/AppRun.lib" ]] ||
|
||||
AnyLinuxDie "missing quick-sharun startup hooks"
|
||||
for entry in libEGL.so libvulkan.so libmediandk.so; do
|
||||
[[ -r "${app_dir}/lib/mocktail/${entry}" ]] ||
|
||||
AnyLinuxDie "Android adapter was not deployed in its private directory: ${entry}"
|
||||
done
|
||||
for entry in roblox_compatibility.json roblox_bootstrap_sources.json \
|
||||
roblox_host_abi_reference.json roblox_signing_certificates.json; do
|
||||
[[ -r "${app_dir}/share/mocktail/metadata/${entry}" ]] ||
|
||||
AnyLinuxDie "quick-sharun did not deploy metadata: ${entry}"
|
||||
done
|
||||
if [[ -n "${ANYLINUX_FREEBSD_HELPER}" ]]; then
|
||||
cmp -s -- "${ANYLINUX_FREEBSD_HELPER}" \
|
||||
"${app_dir}/share/mocktail/helpers/mocktail_freebsd_socket_helper" ||
|
||||
AnyLinuxDie "the static FreeBSD helper was changed or omitted"
|
||||
fi
|
||||
}
|
||||
|
||||
AnyLinuxPrepareInstallTree() {
|
||||
local prefix="$1"
|
||||
# This static FreeBSD executable must not be turned into a Linux sharun
|
||||
# wrapper. Keep it as application data, outside the nested lib executables
|
||||
# that quick-sharun wraps. The native runtime uses its explicit .env path.
|
||||
local helper="${prefix}/lib/mocktail/mocktail_freebsd_socket_helper"
|
||||
if [[ -f "${helper}" ]]; then
|
||||
install -d -- "${prefix}/share/mocktail/helpers"
|
||||
mv -- "${helper}" "${prefix}/share/mocktail/helpers/"
|
||||
fi
|
||||
}
|
||||
|
||||
AnyLinuxDeploy() {
|
||||
AnyLinuxVerifyInstalled
|
||||
local app_dir="${ANYLINUX_WORK}/AppDir"
|
||||
mkdir -p -- "${app_dir}" "${ANYLINUX_WORK}/tmp"
|
||||
# .env is runtime configuration, not a custom AppRun. The generated
|
||||
# upstream entry point must execute all relocation/certificate hooks.
|
||||
install -m 0644 -- "${ANYLINUX_PROJECT_ROOT}/packaging/anylinux.env" \
|
||||
"${app_dir}/.env"
|
||||
|
||||
local -a deployment_environment=(
|
||||
"APPDIR=${app_dir}" "MAIN_BIN=mocktail" "LIB_DIR=/usr/lib"
|
||||
"TMPDIR=${ANYLINUX_WORK}/tmp"
|
||||
"DESKTOP=/usr/share/applications/space.bigrat.mocktail.desktop"
|
||||
"ICON=/usr/share/icons/hicolor/scalable/apps/space.bigrat.mocktail.svg"
|
||||
"DEPLOY_DATADIR=0" "DEPLOY_LOCALE=0" "DEPLOY_VULKAN=0"
|
||||
"STRACE_MODE=1" "STRACE_BINARY=mocktail" "STRACE_FLAGS=--help"
|
||||
"STRACE_TIME=1"
|
||||
)
|
||||
if [[ -n "${MOCKTAIL_ANYLINUX_SHARUN:-}" ]]; then
|
||||
[[ -x "${MOCKTAIL_ANYLINUX_SHARUN}" ]] || AnyLinuxDie "invalid sharun input"
|
||||
deployment_environment+=("SHARUN_LINK=file://$(realpath -e -- "${MOCKTAIL_ANYLINUX_SHARUN}")")
|
||||
fi
|
||||
if [[ -n "${MOCKTAIL_ANYLINUX_C_SOURCE:-}" ]]; then
|
||||
[[ -r "${MOCKTAIL_ANYLINUX_C_SOURCE}" ]] || AnyLinuxDie "invalid anylinux.c input"
|
||||
deployment_environment+=("ANYLINUX_LIB_SOURCE=file://$(realpath -e -- "${MOCKTAIL_ANYLINUX_C_SOURCE}")")
|
||||
fi
|
||||
# Upstream prefers wget, which cannot fetch file:// inputs. Adapt only
|
||||
# those local tool downloads; all network downloads still use real wget.
|
||||
if [[ -n "${MOCKTAIL_ANYLINUX_SHARUN:-}${MOCKTAIL_ANYLINUX_C_SOURCE:-}" ]] &&
|
||||
command -v wget >/dev/null; then
|
||||
local downloader_dir="${ANYLINUX_WORK}/download-tools"
|
||||
mkdir -p -- "${downloader_dir}"
|
||||
cat >"${downloader_dir}/wget" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
if [[ "$#" == 3 && "$1" == -qO && "$3" == file://* ]]; then
|
||||
cp -- "${3#file://}" "$2"
|
||||
else
|
||||
exec "${MOCKTAIL_ANYLINUX_REAL_WGET}" "$@"
|
||||
fi
|
||||
EOF
|
||||
chmod 0755 -- "${downloader_dir}/wget"
|
||||
deployment_environment+=(
|
||||
"MOCKTAIL_ANYLINUX_REAL_WGET=$(command -v wget)"
|
||||
"PATH=${downloader_dir}:${PATH}"
|
||||
)
|
||||
fi
|
||||
|
||||
printf '[anylinux] deploying the native /usr installation\n' >&2
|
||||
env "${deployment_environment[@]}" "${ANYLINUX_TOOL}" \
|
||||
/usr/bin/mocktail \
|
||||
/usr/lib/mocktail/mocktail_webview_helper \
|
||||
/usr/lib/mocktail/mocktail_failure_dialog \
|
||||
/usr/lib/mocktail/mocktail_updater \
|
||||
/usr/lib/mocktail /usr/share/mocktail /usr/bin/bash
|
||||
AnyLinuxVerifyAppDir "${app_dir}"
|
||||
|
||||
# Use the upstream creation entry point, rather than maintaining a second
|
||||
# appimagetool invocation and a second copy of the userspace libraries.
|
||||
env "${deployment_environment[@]}" \
|
||||
APPIMAGETOOL="${ANYLINUX_APPIMAGETOOL}" \
|
||||
OUTPATH="${ANYLINUX_WORK}/image" \
|
||||
OUTNAME="$(basename -- "${ANYLINUX_OUTPUT}")" \
|
||||
"${ANYLINUX_TOOL}" --make-appimage
|
||||
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
|
||||
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
|
||||
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
|
||||
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
|
||||
}
|
||||
|
||||
AnyLinuxMain() {
|
||||
AnyLinuxParseArguments "$@"
|
||||
local command_name
|
||||
for command_name in cmake readelf realpath "${ANYLINUX_TOOL}" "${ANYLINUX_APPIMAGETOOL}"; do
|
||||
command -v "${command_name}" >/dev/null ||
|
||||
AnyLinuxDie "missing required command: ${command_name}"
|
||||
done
|
||||
ANYLINUX_TOOL="$(realpath -e -- "$(command -v "${ANYLINUX_TOOL}")")"
|
||||
ANYLINUX_APPIMAGETOOL="$(realpath -e -- "$(command -v "${ANYLINUX_APPIMAGETOOL}")")"
|
||||
export MOCKTAIL_ANYLINUX_PACKAGER="${ANYLINUX_TOOL}"
|
||||
export MOCKTAIL_ANYLINUX_APPIMAGETOOL="${ANYLINUX_APPIMAGETOOL}"
|
||||
mkdir -p -- "$(dirname -- "${ANYLINUX_OUTPUT}")"
|
||||
ANYLINUX_WORK="$(mktemp -d "$(dirname -- "${ANYLINUX_OUTPUT}")/.mocktail-anylinux.XXXXXX")"
|
||||
trap AnyLinuxCleanup EXIT
|
||||
if [[ "${ANYLINUX_DEPLOY_INSTALLED}" == true ]]; then
|
||||
AnyLinuxDeploy
|
||||
return
|
||||
fi
|
||||
|
||||
ANYLINUX_BUILD_DIR="$(realpath -e -- "${ANYLINUX_BUILD_DIR}")"
|
||||
[[ -f "${ANYLINUX_BUILD_DIR}/cmake_install.cmake" ]] ||
|
||||
AnyLinuxDie "build directory has no CMake installation rules"
|
||||
local install_root="${ANYLINUX_WORK}/install"
|
||||
if [[ "${ANYLINUX_SYSTEM_INSTALL}" == true ]]; then
|
||||
[[ -f /.dockerenv || -f /run/.containerenv ]] ||
|
||||
AnyLinuxDie "--system-install is only allowed in a disposable container"
|
||||
[[ "$(id -u)" == 0 ]] || AnyLinuxDie "container installation requires root"
|
||||
env -u DESTDIR cmake --install "${ANYLINUX_BUILD_DIR}" --prefix /usr
|
||||
AnyLinuxPrepareInstallTree /usr
|
||||
AnyLinuxDeploy
|
||||
return
|
||||
fi
|
||||
|
||||
command -v bwrap >/dev/null || AnyLinuxDie "bwrap is required for a private /usr install"
|
||||
DESTDIR="${install_root}" cmake --install "${ANYLINUX_BUILD_DIR}" --prefix /usr
|
||||
AnyLinuxPrepareInstallTree "${install_root}/usr"
|
||||
# Overlay only /usr; keep the host root read-only, with narrowly scoped
|
||||
# writable packaging scratch/output directories. No host package install.
|
||||
bwrap --die-with-parent --ro-bind / / --dev-bind /dev /dev --proc /proc \
|
||||
--overlay-src /usr --overlay-src "${install_root}/usr" --ro-overlay /usr \
|
||||
--bind "$(dirname -- "${ANYLINUX_OUTPUT}")" "$(dirname -- "${ANYLINUX_OUTPUT}")" \
|
||||
--bind /tmp /tmp \
|
||||
-- "${BASH}" "${BASH_SOURCE[0]}" --deploy-installed --appimage "${ANYLINUX_OUTPUT}"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
AnyLinuxMain "$@"
|
||||
fi
|
||||
+31
-25
@@ -28,6 +28,7 @@ STAGING=""
|
||||
APPDIR=""
|
||||
PACKAGING_LIBRARY_PATH=""
|
||||
ANDROID_TOOLS_ABI=""
|
||||
APPIMAGE_FORMAT="${MOCKTAIL_APPIMAGE_FORMAT:-classic}"
|
||||
|
||||
readonly -a PROJECT_ARTIFACTS=(
|
||||
mocktail
|
||||
@@ -51,19 +52,7 @@ readonly -a PROJECT_ARTIFACTS=(
|
||||
readonly FREEBSD_SOCKET_HELPER="mocktail_freebsd_socket_helper"
|
||||
|
||||
readonly -a RUNTIME_SCRIPTS=(
|
||||
auto_update_roblox.sh
|
||||
derive_roblox_host_abi_profile.py
|
||||
fetch_roblox_apks.sh
|
||||
update_roblox_payload.sh
|
||||
payload_store.sh
|
||||
payload_integrity.sh
|
||||
real_bringup_smoke.sh
|
||||
audio_readiness_gate.sh
|
||||
input_readiness_gate.sh
|
||||
resize_readiness_gate.sh
|
||||
network_readiness_gate.sh
|
||||
collect_support_bundle.sh
|
||||
read_update_config.py
|
||||
)
|
||||
|
||||
readonly STANDALONE_SUPPORT_PACKAGER="${PROJECT_ROOT}/scripts/package_standalone_support.sh"
|
||||
@@ -86,15 +75,17 @@ Options:
|
||||
--mode MODE standalone (default) or thin; aliases: full, static,
|
||||
dynamic, and legacy minimal.
|
||||
--dry-run Audit artifacts and dependency policy without writing.
|
||||
--appimage FILE Also build FILE with appimagetool.
|
||||
--appimage FILE Also build FILE. Set MOCKTAIL_APPIMAGE_FORMAT to
|
||||
classic (default) or anylinux. AnyLinux uses a native
|
||||
/usr install instead of producing a portable directory.
|
||||
-h, --help Show this help.
|
||||
|
||||
standalone bundles the application userspace and support tools. thin omits
|
||||
copied dependency libraries and bootstraps host packages before launch.
|
||||
The physical GPU kernel driver and vendor ICD remain host-provided.
|
||||
|
||||
Every Mocktail ELF build artifact must match --libc. glibc bundles use compact
|
||||
native aapt plus the Java apksigner library. A musl standalone bundle uses the
|
||||
Every Mocktail ELF build artifact must match --libc. Classic glibc bundles use
|
||||
compact native aapt plus the Java apksigner library. A musl standalone bundle uses the
|
||||
Java apkanalyzer backend instead of hiding a glibc runtime dependency.
|
||||
No Android SDK development tree is published. Public release artifacts are:
|
||||
Mocktail-x86_64-<libc>-<standalone|thin>.AppImage.
|
||||
@@ -165,6 +156,12 @@ ParseArguments() {
|
||||
Die "--mode must be standalone or thin"
|
||||
[[ "${TARGET_LIBC}" == glibc || "${TARGET_LIBC}" == musl ]] ||
|
||||
Die "--libc must be glibc or musl"
|
||||
[[ "${APPIMAGE_FORMAT}" == classic ||
|
||||
"${APPIMAGE_FORMAT}" == anylinux ]] ||
|
||||
Die "MOCKTAIL_APPIMAGE_FORMAT must be classic or anylinux"
|
||||
if [[ "${APPIMAGE_FORMAT}" == anylinux && "${TARGET_LIBC}" != glibc ]]; then
|
||||
Die "AnyLinux packaging requires a glibc build"
|
||||
fi
|
||||
|
||||
if [[ "${TARGET_LIBC}" == musl && "${MODE}" == standalone ]]; then
|
||||
ANDROID_TOOLS_ABI=java
|
||||
@@ -475,8 +472,8 @@ AuditThinDependencies() {
|
||||
CopyRuntimeTree() {
|
||||
local runtime_root="${STAGING}/mocktail"
|
||||
mkdir -p -- "${runtime_root}/bin" "${runtime_root}/lib" \
|
||||
"${runtime_root}/scripts/apk_providers" \
|
||||
"${runtime_root}/metadata" "${runtime_root}/runtime"
|
||||
"${runtime_root}/scripts" "${runtime_root}/metadata" \
|
||||
"${runtime_root}/runtime"
|
||||
chmod 0755 -- "${STAGING}"
|
||||
|
||||
local artifact mode
|
||||
@@ -504,11 +501,6 @@ CopyRuntimeTree() {
|
||||
install -m 0755 -- "${PROJECT_ROOT}/scripts/${script}" \
|
||||
"${runtime_root}/scripts/${script}"
|
||||
done
|
||||
install -m 0755 -- "${PROJECT_ROOT}/scripts/apk_providers/direct_apkpure.py" \
|
||||
"${PROJECT_ROOT}/scripts/apk_providers/direct_apkpure.sh" \
|
||||
"${PROJECT_ROOT}/scripts/apk_providers/direct_uptodown.py" \
|
||||
"${PROJECT_ROOT}/scripts/apk_providers/direct_uptodown.sh" \
|
||||
"${runtime_root}/scripts/apk_providers/"
|
||||
}
|
||||
|
||||
CopyApkAnalyzerClasspath() {
|
||||
@@ -869,7 +861,7 @@ VerifyBundle() {
|
||||
if [[ "${MODE}" == standalone ]]; then
|
||||
local support_elf
|
||||
for support_elf in \
|
||||
runtime/bin/bash runtime/bin/python3 runtime/jre/bin/java \
|
||||
runtime/bin/bash runtime/jre/bin/java \
|
||||
runtime/bin/bwrap runtime/bin/xdg-dbus-proxy \
|
||||
libexec/webkitgtk-6.0/WebKitWebProcess \
|
||||
libexec/gstreamer-1.0/gst-plugin-scanner; do
|
||||
@@ -920,7 +912,6 @@ PublishBundle() {
|
||||
BuildAppImage() {
|
||||
local icon_path icon_relative_path
|
||||
[[ -n "${APPIMAGE_OUTPUT}" ]] || return 0
|
||||
RequireCommand appimagetool
|
||||
APPDIR="$(mktemp -d "${OUTPUT%/*}/.Mocktail.AppDir.XXXXXX")"
|
||||
mkdir -p "${APPDIR}/usr/share/mocktail-bundle" \
|
||||
"${APPDIR}/usr/share/applications" \
|
||||
@@ -976,6 +967,19 @@ DryRun() {
|
||||
|
||||
Main() {
|
||||
ParseArguments "$@"
|
||||
if [[ -n "${APPIMAGE_OUTPUT}" && "${APPIMAGE_FORMAT}" == anylinux ]]; then
|
||||
[[ "${MODE}" == standalone ]] ||
|
||||
Die "AnyLinux is self-contained; use classic for a thin bundle"
|
||||
[[ "${DRY_RUN}" == false ]] ||
|
||||
Die "AnyLinux packaging requires a real native install, not a portable dry-run"
|
||||
local -a anylinux_arguments=(
|
||||
--build-dir "${BUILD_DIR}" --appimage "${APPIMAGE_OUTPUT}"
|
||||
)
|
||||
if [[ "${MOCKTAIL_ANYLINUX_SYSTEM_INSTALL:-0}" == 1 ]]; then
|
||||
anylinux_arguments+=(--system-install)
|
||||
fi
|
||||
exec "${PROJECT_ROOT}/scripts/package_anylinux.sh" "${anylinux_arguments[@]}"
|
||||
fi
|
||||
RequireCommand readelf
|
||||
RequireCommand patchelf
|
||||
RequireCommand sha256sum
|
||||
@@ -1022,4 +1026,6 @@ Main() {
|
||||
BuildAppImage
|
||||
}
|
||||
|
||||
Main "$@"
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
Main "$@"
|
||||
fi
|
||||
@@ -48,24 +48,14 @@ readonly -a REQUIRED_COMMANDS=(
|
||||
uname
|
||||
)
|
||||
|
||||
readonly -a PYTHON_DISTRIBUTIONS=(
|
||||
capstone
|
||||
PyYAML
|
||||
requests
|
||||
urllib3
|
||||
certifi
|
||||
charset-normalizer
|
||||
idna
|
||||
)
|
||||
|
||||
Usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/package_standalone_support.sh --runtime-root DIR --libc ABI
|
||||
|
||||
Populate DIR/runtime with the native command, Python, Java, and CA support
|
||||
needed by the standalone Mocktail scripts. ABI must be glibc or musl and must
|
||||
match the build host. ELF dependency closure is intentionally left to the
|
||||
parent packager.
|
||||
Populate DIR/runtime with the native command, Java, and CA support needed by
|
||||
the standalone Mocktail launcher. ABI must be glibc or musl and must match the
|
||||
build host. ELF dependency closure is intentionally left to the parent
|
||||
packager.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -125,7 +115,7 @@ ParseArguments() {
|
||||
|
||||
RequireBuildCommands() {
|
||||
local command_name
|
||||
for command_name in cp cmp install java jlink ldd ln mkdir mv python3 \
|
||||
for command_name in cp cmp install java jlink ldd ln mkdir mv \
|
||||
readlink realpath rm rmdir; do
|
||||
command -v "${command_name}" >/dev/null 2>&1 ||
|
||||
Die "required build command is unavailable: ${command_name}"
|
||||
@@ -197,16 +187,6 @@ ValidateNativeHost() {
|
||||
Die "requested ${TARGET_LIBC} support on a ${host_libc} host; cross-packaging is unsupported"
|
||||
}
|
||||
|
||||
ValidateRelativePath() {
|
||||
local -r path="$1"
|
||||
RejectUnsafeText "${path}" "relative path"
|
||||
[[ "${path}" != /* && "${path}" != . && "${path}" != .. &&
|
||||
"${path}" != ../* && "${path}" != */../* &&
|
||||
"${path}" != */.. && "${path}" != ./* &&
|
||||
"${path}" != */./* && "${path}" != */. &&
|
||||
"${path}" != *//* ]] || Die "unsafe relative path: ${path}"
|
||||
}
|
||||
|
||||
PathIsInside() {
|
||||
local -r path="$1"
|
||||
local -r root="$2"
|
||||
@@ -229,87 +209,6 @@ InstallRegularFile() {
|
||||
cp -p -- "${source}" "${destination}"
|
||||
}
|
||||
|
||||
CopySafeLink() {
|
||||
local -r source="$1"
|
||||
local -r source_root="$2"
|
||||
local -r destination="$3"
|
||||
local -r destination_root="$4"
|
||||
local target resolved relative_target destination_target
|
||||
target="$(readlink -- "${source}")" || Die "cannot read symlink: ${source}"
|
||||
RejectUnsafeText "${target}" "symlink target"
|
||||
resolved="$(realpath -e -- "${source}")" ||
|
||||
Die "dangling symlink is not permitted: ${source}"
|
||||
if ! PathIsInside "${resolved}" "${source_root}"; then
|
||||
# Host packages sometimes point a data file (not code) at a canonical
|
||||
# system copy, for example certifi/cacert.pem. Materialize that file so
|
||||
# the packaged tree does not retain an absolute or escaping link.
|
||||
[[ -f "${resolved}" && ! -L "${resolved}" ]] ||
|
||||
Die "symlink escapes its copied tree: ${source} -> ${target}"
|
||||
InstallRegularFile "${resolved}" "${destination}"
|
||||
return
|
||||
fi
|
||||
|
||||
relative_target="$(realpath --relative-to="$(dirname -- "${source}")" -- \
|
||||
"${resolved}")" || Die "cannot normalize symlink: ${source}"
|
||||
ValidateRelativePath "${relative_target}"
|
||||
destination_target="$(realpath -m -- \
|
||||
"$(dirname -- "${destination}")/${relative_target}")"
|
||||
PathIsInside "${destination_target}" "${destination_root}" ||
|
||||
Die "rewritten symlink escapes destination: ${destination}"
|
||||
mkdir -p -- "$(dirname -- "${destination}")"
|
||||
if [[ -e "${destination}" || -L "${destination}" ]]; then
|
||||
[[ -L "${destination}" &&
|
||||
"$(readlink -- "${destination}")" == "${relative_target}" ]] ||
|
||||
Die "support symlink collision: ${destination}"
|
||||
return
|
||||
fi
|
||||
ln -s -- "${relative_target}" "${destination}"
|
||||
}
|
||||
|
||||
CopyTree() {
|
||||
local -r source_input="$1"
|
||||
local -r destination_root="$2"
|
||||
local -r prune_python_packages="${3:-false}"
|
||||
local source_root
|
||||
source_root="$(realpath -e -- "${source_input}")" ||
|
||||
Die "cannot resolve source tree: ${source_input}"
|
||||
[[ -d "${source_root}" && ! -L "${source_root}" ]] ||
|
||||
Die "source tree must resolve to a directory: ${source_input}"
|
||||
mkdir -p -- "${destination_root}"
|
||||
|
||||
local entry relative destination
|
||||
while IFS= read -r -d '' entry; do
|
||||
relative="${entry#"${source_root}"/}"
|
||||
ValidateRelativePath "${relative}"
|
||||
destination="${destination_root}/${relative}"
|
||||
if [[ -L "${entry}" ]]; then
|
||||
CopySafeLink "${entry}" "${source_root}" "${destination}" \
|
||||
"${destination_root}"
|
||||
elif [[ -d "${entry}" ]]; then
|
||||
if [[ -e "${destination}" && ! -d "${destination}" ]]; then
|
||||
Die "support directory collision: ${destination}"
|
||||
fi
|
||||
mkdir -p -- "${destination}"
|
||||
chmod --reference="${entry}" "${destination}"
|
||||
elif [[ -f "${entry}" ]]; then
|
||||
InstallRegularFile "${entry}" "${destination}"
|
||||
else
|
||||
Die "unsupported special file in source tree: ${entry}"
|
||||
fi
|
||||
done < <(
|
||||
if [[ "${prune_python_packages}" == true ]]; then
|
||||
find -P "${source_root}" -mindepth 1 \
|
||||
\( -type d \( -name site-packages -o -name dist-packages -o \
|
||||
-name __pycache__ \) -prune \) -o \
|
||||
\( -type f -name '*.pyc' -prune \) -o -print0
|
||||
else
|
||||
find -P "${source_root}" -mindepth 1 \
|
||||
\( -type d -name __pycache__ -prune \) -o \
|
||||
\( -type f -name '*.pyc' -prune \) -o -print0
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
CopyCommand() {
|
||||
local -r name="$1"
|
||||
local source resolved target_name
|
||||
@@ -345,161 +244,6 @@ CopyRuntimeCommands() {
|
||||
fi
|
||||
}
|
||||
|
||||
PythonValue() {
|
||||
local -r expression="$1"
|
||||
python3 -c "import sys, sysconfig; print(${expression})"
|
||||
}
|
||||
|
||||
PythonDistributionFiles() {
|
||||
local -r distribution="$1"
|
||||
python3 - "${distribution}" <<'PY'
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
def find_import_root(source):
|
||||
candidates = []
|
||||
for entry in sys.path:
|
||||
if not entry:
|
||||
continue
|
||||
try:
|
||||
root = pathlib.Path(entry).resolve(strict=True)
|
||||
source.relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
candidates.append(root)
|
||||
if not candidates:
|
||||
raise SystemExit(1)
|
||||
return max(candidates, key=lambda path: len(path.parts))
|
||||
|
||||
name = sys.argv[1]
|
||||
try:
|
||||
installed_distribution = importlib.metadata.distribution(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
installed_distribution = None
|
||||
|
||||
entries = []
|
||||
if installed_distribution is not None and installed_distribution.files:
|
||||
root = pathlib.Path(
|
||||
installed_distribution.locate_file("")
|
||||
).resolve(strict=True)
|
||||
for entry in installed_distribution.files:
|
||||
entries.append((
|
||||
root,
|
||||
pathlib.Path(installed_distribution.locate_file(entry)),
|
||||
pathlib.PurePath(entry),
|
||||
))
|
||||
else:
|
||||
fallback_modules = {
|
||||
"capstone": ("capstone",),
|
||||
"pyyaml": ("yaml", "_yaml"),
|
||||
"requests": ("requests",),
|
||||
"urllib3": ("urllib3",),
|
||||
"certifi": ("certifi",),
|
||||
"charset-normalizer": ("charset_normalizer",),
|
||||
"idna": ("idna",),
|
||||
}.get(name.lower(), ())
|
||||
discovered = []
|
||||
for module in fallback_modules:
|
||||
spec = importlib.util.find_spec(module)
|
||||
if spec is None:
|
||||
raise SystemExit(1)
|
||||
if spec.submodule_search_locations is not None:
|
||||
module_root = pathlib.Path(
|
||||
next(iter(spec.submodule_search_locations))
|
||||
).resolve(strict=True)
|
||||
discovered.extend(
|
||||
path for path in module_root.rglob("*")
|
||||
if path.is_file() or path.is_symlink()
|
||||
)
|
||||
elif spec.origin:
|
||||
discovered.append(pathlib.Path(spec.origin).resolve(strict=True))
|
||||
for source in discovered:
|
||||
root = find_import_root(source)
|
||||
entries.append((root, source, source.relative_to(root)))
|
||||
written = 0
|
||||
for root, source, relative in entries:
|
||||
relative = pathlib.PurePath(relative)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
continue
|
||||
if "__pycache__" in relative.parts or relative.suffix == ".pyc":
|
||||
continue
|
||||
if not source.exists() and not source.is_symlink():
|
||||
raise SystemExit(1)
|
||||
os.write(1, os.fsencode(root) + b"\0")
|
||||
os.write(1, os.fsencode(source) + b"\0")
|
||||
os.write(1, os.fsencode(relative) + b"\0")
|
||||
written += 1
|
||||
if written == 0:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
CopyPythonRuntime() {
|
||||
local python_executable python_resolved python_version stdlib platstdlib
|
||||
python_executable="$(type -P -- python3 || true)"
|
||||
[[ -n "${python_executable}" ]] || Die "python3 is required to build support"
|
||||
python_resolved="$(realpath -e -- "${python_executable}")" ||
|
||||
Die "cannot resolve python3"
|
||||
[[ -f "${python_resolved}" && -x "${python_resolved}" ]] ||
|
||||
Die "python3 does not resolve to an executable regular file"
|
||||
python_version="$(PythonValue 'f"{sys.version_info.major}.{sys.version_info.minor}"')"
|
||||
[[ "${python_version}" =~ ^[0-9]+\.[0-9]+$ ]] ||
|
||||
Die "cannot determine Python version"
|
||||
stdlib="$(PythonValue 'sysconfig.get_path("stdlib")')"
|
||||
platstdlib="$(PythonValue 'sysconfig.get_path("platstdlib")')"
|
||||
RejectUnsafeText "${stdlib}" "Python stdlib path"
|
||||
RejectUnsafeText "${platstdlib}" "Python platform stdlib path"
|
||||
|
||||
local -r python_bin="${SUPPORT_RUNTIME}/python/bin"
|
||||
local -r python_library="${SUPPORT_RUNTIME}/python/lib/python${python_version}"
|
||||
mkdir -p -- "${python_bin}" "${python_library}"
|
||||
local python_binary_name
|
||||
python_binary_name="$(basename -- "${python_resolved}")"
|
||||
InstallRegularFile "${python_resolved}" \
|
||||
"${python_bin}/${python_binary_name}"
|
||||
if [[ "${python_binary_name}" != python3 ]]; then
|
||||
ln -s -- "${python_binary_name}" "${python_bin}/python3"
|
||||
fi
|
||||
ln -s -- "../python/bin/python3" "${SUPPORT_RUNTIME}/bin/python3"
|
||||
|
||||
CopyTree "${stdlib}" "${python_library}" true
|
||||
local canonical_stdlib canonical_platstdlib
|
||||
canonical_stdlib="$(realpath -e -- "${stdlib}")"
|
||||
canonical_platstdlib="$(realpath -e -- "${platstdlib}")"
|
||||
if [[ "${canonical_platstdlib}" != "${canonical_stdlib}" ]]; then
|
||||
CopyTree "${platstdlib}" "${python_library}" true
|
||||
fi
|
||||
|
||||
local distribution distribution_root source relative destination
|
||||
for distribution in "${PYTHON_DISTRIBUTIONS[@]}"; do
|
||||
local copied=0
|
||||
while IFS= read -r -d '' distribution_root &&
|
||||
IFS= read -r -d '' source &&
|
||||
IFS= read -r -d '' relative; do
|
||||
ValidateRelativePath "${relative}"
|
||||
distribution_root="$(realpath -e -- "${distribution_root}")" ||
|
||||
Die "cannot resolve Python distribution root: ${distribution}"
|
||||
destination="${python_library}/${relative}"
|
||||
if [[ -L "${source}" ]]; then
|
||||
CopySafeLink "${source}" "${distribution_root}" "${destination}" \
|
||||
"${python_library}"
|
||||
elif [[ -f "${source}" ]]; then
|
||||
InstallRegularFile "${source}" "${destination}"
|
||||
elif [[ ! -d "${source}" ]]; then
|
||||
Die "unsupported Python distribution entry: ${source}"
|
||||
fi
|
||||
((copied += 1))
|
||||
done < <(PythonDistributionFiles "${distribution}")
|
||||
(( copied > 0 )) ||
|
||||
Die "required Python distribution is unavailable: ${distribution}"
|
||||
done
|
||||
|
||||
}
|
||||
|
||||
CopyCaBundle() {
|
||||
local -a candidates=()
|
||||
if [[ -n "${SSL_CERT_FILE:-}" ]]; then
|
||||
@@ -549,6 +293,22 @@ BuildJlinkRuntime() {
|
||||
--strip-debug --no-man-pages \
|
||||
--no-header-files --compress="${compression}" \
|
||||
--output "${SUPPORT_RUNTIME}/jre"
|
||||
|
||||
# apkanalyzer needs java.desktop data structures, but neither it nor
|
||||
# apksigner uses Java Sound. Some OpenJDK builds link this optional native
|
||||
# library against ALSA, which is intentionally absent from the AnyLinux
|
||||
# build image and must not become a standalone runtime dependency.
|
||||
local java_sound_library
|
||||
for java_sound_library in \
|
||||
"${SUPPORT_RUNTIME}/jre/lib/libjsound.so" \
|
||||
"${SUPPORT_RUNTIME}/jre/lib/libjsoundalsa.so"; do
|
||||
if [[ -e "${java_sound_library}" || -L "${java_sound_library}" ]]; then
|
||||
[[ ! -d "${java_sound_library}" ]] ||
|
||||
Die "Java Sound library path is unexpectedly a directory"
|
||||
rm -f -- "${java_sound_library}"
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -x "${SUPPORT_RUNTIME}/jre/bin/java" ]] ||
|
||||
Die "jlink did not produce a Java launcher"
|
||||
ln -s -- "../jre/bin/java" "${SUPPORT_RUNTIME}/bin/java"
|
||||
@@ -573,27 +333,8 @@ ValidateCopiedSymlinks() {
|
||||
}
|
||||
|
||||
VerifySupportRuntime() {
|
||||
local -r python="${SUPPORT_RUNTIME}/bin/python3"
|
||||
local -r ca_bundle="${SUPPORT_RUNTIME}/share/ca-certificates/ca-bundle.crt"
|
||||
[[ -x "${python}" && -s "${ca_bundle}" ]] ||
|
||||
Die "packaged Python or CA support is incomplete"
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONNOUSERSITE=1 "${python}" -s -c '
|
||||
import pathlib
|
||||
import sys
|
||||
import capstone
|
||||
import yaml
|
||||
import certifi
|
||||
import charset_normalizer
|
||||
import idna
|
||||
import requests
|
||||
import urllib3
|
||||
if capstone.cs_version()[0] != 5:
|
||||
raise SystemExit("bundled capstone major version is not 5")
|
||||
prefix = pathlib.Path(sys.prefix).resolve()
|
||||
expected = pathlib.Path(sys.executable).resolve().parents[1]
|
||||
if prefix != expected:
|
||||
raise SystemExit(f"Python prefix escaped support runtime: {prefix}")
|
||||
' || Die "relocated Python dependency verification failed"
|
||||
[[ -s "${ca_bundle}" ]] || Die "packaged CA support is incomplete"
|
||||
ValidateCopiedSymlinks "${SUPPORT_RUNTIME}"
|
||||
}
|
||||
|
||||
@@ -621,12 +362,11 @@ Main() {
|
||||
"${RUNTIME_ROOT}/.mocktail-standalone-support.XXXXXX")" ||
|
||||
Die "cannot create support staging directory"
|
||||
SUPPORT_RUNTIME="${STAGING_ROOT}/runtime"
|
||||
mkdir -p -- "${SUPPORT_RUNTIME}/bin" "${SUPPORT_RUNTIME}/python" \
|
||||
"${SUPPORT_RUNTIME}/jre" "${SUPPORT_RUNTIME}/share"
|
||||
mkdir -p -- "${SUPPORT_RUNTIME}/bin" "${SUPPORT_RUNTIME}/jre" \
|
||||
"${SUPPORT_RUNTIME}/share"
|
||||
rmdir -- "${SUPPORT_RUNTIME}/jre"
|
||||
|
||||
CopyRuntimeCommands
|
||||
CopyPythonRuntime
|
||||
CopyCaBundle
|
||||
BuildJlinkRuntime
|
||||
VerifySupportRuntime
|
||||
|
||||
@@ -435,12 +435,16 @@ CopyGdkPixbufResources() {
|
||||
"${STAGING}/share/glycin-loaders/2+/conf.d"
|
||||
local executable config
|
||||
while IFS= read -r -d '' executable; do
|
||||
# Arch ships the HEIF loader even when its optional libheif dependency is
|
||||
# absent. Mocktail's WebKit UI does not require HEIF/AVIF decoding.
|
||||
[[ "${executable##*/}" != glycin-heif ]] || continue
|
||||
ValidateElf "${executable}" "glycin image loader"
|
||||
install -m 0755 -- "${executable}" \
|
||||
"${STAGING}/lib/plugins/glycin-loaders/2+/${executable##*/}"
|
||||
done < <(find -P "${GLYCIN_BINARY_SOURCE}" -maxdepth 1 -type f \
|
||||
-perm -0100 -print0 | sort -z)
|
||||
while IFS= read -r -d '' config; do
|
||||
[[ "${config##*/}" != glycin-heif.conf ]] || continue
|
||||
sed -E \
|
||||
's#^([[:space:]]*Exec=).*/([^/[:space:]]+)[[:space:]]*$#\1lib/plugins/glycin-loaders/2+/\2#' \
|
||||
"${config}" \
|
||||
@@ -481,7 +485,7 @@ CopyGtkData() {
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<dir>share/fonts</dir>
|
||||
<dir prefix="relative">../fonts</dir>
|
||||
<cachedir prefix="xdg">fontconfig</cachedir>
|
||||
</fontconfig>
|
||||
EOF
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "mocktail/audio/opensl_playback_runtime.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -8,7 +9,9 @@
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/audio/audio_sink.h"
|
||||
#include "mocktail/audio/opensl_simple_buffer_queue.h"
|
||||
@@ -89,6 +92,9 @@ extern const abi::PlayTable kPlayTable;
|
||||
extern const abi::VolumeTable kVolumeTable;
|
||||
extern const abi::AndroidConfigurationTable kConfigurationTable;
|
||||
|
||||
std::mutex g_active_objects_mutex;
|
||||
std::unordered_set<RuntimeObject*> g_active_objects;
|
||||
|
||||
struct RuntimeObject {
|
||||
explicit RuntimeObject(RuntimeObjectKind object_kind) : kind(object_kind) {
|
||||
object_handle = ObjectHandle{&kObjectTable, this};
|
||||
@@ -96,6 +102,13 @@ struct RuntimeObject {
|
||||
play_handle = PlayHandle{&kPlayTable, this};
|
||||
volume_handle = VolumeHandle{&kVolumeTable, this};
|
||||
configuration_handle = ConfigurationHandle{&kConfigurationTable, this};
|
||||
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
|
||||
g_active_objects.insert(this);
|
||||
}
|
||||
|
||||
~RuntimeObject() {
|
||||
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
|
||||
g_active_objects.erase(this);
|
||||
}
|
||||
|
||||
abi::Object ObjectInterface() {
|
||||
@@ -133,6 +146,9 @@ struct RuntimeObject {
|
||||
abi::Int32 priority = 0;
|
||||
abi::Boolean preemptable = abi::kBooleanFalse;
|
||||
|
||||
RuntimeObject* parent_engine = nullptr;
|
||||
std::vector<RuntimeObject*> children;
|
||||
|
||||
PcmSpec pcm_spec;
|
||||
abi::Uint32 queue_capacity = 0;
|
||||
bool expose_queue = false;
|
||||
@@ -150,14 +166,20 @@ struct RuntimeObject {
|
||||
std::atomic<bool> consumption_reported{false};
|
||||
};
|
||||
|
||||
RuntimeObject* ResolveActiveObject(RuntimeObject* object) {
|
||||
if (object == nullptr || object->magic != kRuntimeObjectMagic) {
|
||||
return nullptr;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
|
||||
return g_active_objects.find(object) != g_active_objects.end() ? object : nullptr;
|
||||
}
|
||||
|
||||
RuntimeObject* FromObject(abi::Object self) {
|
||||
if (self == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto* handle = reinterpret_cast<const ObjectHandle*>(self);
|
||||
RuntimeObject* object = handle->object;
|
||||
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
|
||||
: nullptr;
|
||||
return ResolveActiveObject(handle->object);
|
||||
}
|
||||
|
||||
RuntimeObject* FromEngine(abi::Engine self) {
|
||||
@@ -165,9 +187,7 @@ RuntimeObject* FromEngine(abi::Engine self) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto* handle = reinterpret_cast<const EngineHandle*>(self);
|
||||
RuntimeObject* object = handle->object;
|
||||
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
|
||||
: nullptr;
|
||||
return ResolveActiveObject(handle->object);
|
||||
}
|
||||
|
||||
RuntimeObject* FromPlay(abi::Play self) {
|
||||
@@ -175,9 +195,7 @@ RuntimeObject* FromPlay(abi::Play self) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto* handle = reinterpret_cast<const PlayHandle*>(self);
|
||||
RuntimeObject* object = handle->object;
|
||||
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
|
||||
: nullptr;
|
||||
return ResolveActiveObject(handle->object);
|
||||
}
|
||||
|
||||
RuntimeObject* FromVolume(abi::Volume self) {
|
||||
@@ -185,9 +203,7 @@ RuntimeObject* FromVolume(abi::Volume self) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto* handle = reinterpret_cast<const VolumeHandle*>(self);
|
||||
RuntimeObject* object = handle->object;
|
||||
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
|
||||
: nullptr;
|
||||
return ResolveActiveObject(handle->object);
|
||||
}
|
||||
|
||||
RuntimeObject* FromConfiguration(abi::AndroidConfiguration self) {
|
||||
@@ -195,9 +211,7 @@ RuntimeObject* FromConfiguration(abi::AndroidConfiguration self) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto* handle = reinterpret_cast<const ConfigurationHandle*>(self);
|
||||
RuntimeObject* object = handle->object;
|
||||
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
|
||||
: nullptr;
|
||||
return ResolveActiveObject(handle->object);
|
||||
}
|
||||
|
||||
bool InterfaceIdEquals(abi::InterfaceId left, abi::InterfaceId right) {
|
||||
@@ -547,10 +561,40 @@ abi::Result ObjectRegisterCallback(abi::Object self,
|
||||
void ObjectAbortAsyncOperation(abi::Object /*self*/) {}
|
||||
|
||||
void ObjectDestroy(abi::Object self) {
|
||||
RuntimeObject* object = FromObject(self);
|
||||
if (object == nullptr) {
|
||||
if (self == nullptr) {
|
||||
return;
|
||||
}
|
||||
RuntimeObject* object = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
|
||||
const auto* handle = reinterpret_cast<const ObjectHandle*>(self);
|
||||
object = handle->object;
|
||||
if (object == nullptr ||
|
||||
g_active_objects.find(object) == g_active_objects.end()) {
|
||||
return;
|
||||
}
|
||||
if (object->magic != kRuntimeObjectMagic) {
|
||||
return;
|
||||
}
|
||||
g_active_objects.erase(object);
|
||||
}
|
||||
|
||||
if (object->parent_engine != nullptr) {
|
||||
std::lock_guard<std::mutex> lock(object->parent_engine->mutex);
|
||||
auto& ch = object->parent_engine->children;
|
||||
ch.erase(std::remove(ch.begin(), ch.end(), object), ch.end());
|
||||
object->parent_engine = nullptr;
|
||||
}
|
||||
|
||||
std::vector<RuntimeObject*> children_to_destroy;
|
||||
if (object->kind == RuntimeObjectKind::kEngine) {
|
||||
std::lock_guard<std::mutex> lock(object->mutex);
|
||||
children_to_destroy = std::move(object->children);
|
||||
}
|
||||
for (auto* child : children_to_destroy) {
|
||||
child->parent_engine = nullptr;
|
||||
ObjectDestroy(child->ObjectInterface());
|
||||
}
|
||||
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> queue_adapter;
|
||||
const bool is_player = object->kind == RuntimeObjectKind::kPlayer;
|
||||
@@ -687,6 +731,11 @@ abi::Result EngineCreateAudioPlayer(
|
||||
if (object == nullptr) {
|
||||
return abi::kResultMemoryFailure;
|
||||
}
|
||||
object->parent_engine = engine;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(engine->mutex);
|
||||
engine->children.push_back(object);
|
||||
}
|
||||
object->pcm_spec = pcm_spec;
|
||||
object->queue_capacity = queue_capacity;
|
||||
object->expose_queue = expose_queue;
|
||||
@@ -763,6 +812,11 @@ abi::Result EngineCreateOutputMix(abi::Engine self, abi::Object* mix,
|
||||
if (object == nullptr) {
|
||||
return abi::kResultMemoryFailure;
|
||||
}
|
||||
object->parent_engine = engine;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(engine->mutex);
|
||||
engine->children.push_back(object);
|
||||
}
|
||||
*mix = object->ObjectInterface();
|
||||
return abi::kResultSuccess;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <pthread.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
@@ -13,6 +14,7 @@
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::audio {
|
||||
namespace {
|
||||
@@ -56,6 +58,7 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
std::shared_ptr<State> state;
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t generation = 0;
|
||||
bool in_use = false;
|
||||
};
|
||||
|
||||
struct CallbackJob {
|
||||
@@ -68,7 +71,13 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
: sink(std::move(owned_sink)),
|
||||
max_buffers(options.max_buffers),
|
||||
event_callback(options.event_callback),
|
||||
event_context(options.event_context) {}
|
||||
event_context(options.event_context),
|
||||
ticket_pool(options.max_buffers) {
|
||||
free_tickets.reserve(options.max_buffers);
|
||||
for (std::size_t i = options.max_buffers; i > 0; --i) {
|
||||
free_tickets.push_back(i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
static const AndroidSimpleBufferQueueTable kQueueTable;
|
||||
|
||||
@@ -101,43 +110,49 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
if (state->stopping) {
|
||||
return opensl_abi::kResultPreconditionsViolated;
|
||||
}
|
||||
if (state->pending.size() >= state->max_buffers) {
|
||||
if (state->pending_count.load(std::memory_order_relaxed) >=
|
||||
state->max_buffers ||
|
||||
state->free_tickets.empty()) {
|
||||
return opensl_abi::kResultBufferInsufficient;
|
||||
}
|
||||
const std::size_t slot_index = state->free_tickets.back();
|
||||
state->free_tickets.pop_back();
|
||||
ticket = &state->ticket_pool[slot_index];
|
||||
id = state->next_id++;
|
||||
ticket = new (std::nothrow) ReleaseTicket{state, id, state->generation};
|
||||
if (ticket == nullptr) {
|
||||
return opensl_abi::kResultMemoryFailure;
|
||||
}
|
||||
state->pending.emplace(id, ticket);
|
||||
ticket->state = state;
|
||||
ticket->id = id;
|
||||
ticket->generation = state->generation;
|
||||
ticket->in_use = true;
|
||||
state->pending_count.fetch_add(1, std::memory_order_relaxed);
|
||||
++state->submitted_buffers;
|
||||
}
|
||||
|
||||
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
|
||||
&State::ReleaseBuffer, ticket};
|
||||
const Status status = state->sink->Enqueue(pcm);
|
||||
if (status.ok()) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
++state->submitted_buffers;
|
||||
}
|
||||
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
|
||||
static_cast<std::size_t>(size));
|
||||
return opensl_abi::kResultSuccess;
|
||||
}
|
||||
|
||||
bool delete_ticket = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
state->last_status = status;
|
||||
const auto found = state->pending.find(id);
|
||||
if (found != state->pending.end() && found->second == ticket) {
|
||||
state->pending.erase(found);
|
||||
delete_ticket = true;
|
||||
if (ticket->in_use && ticket->id == id) {
|
||||
ticket->in_use = false;
|
||||
ticket->state.reset();
|
||||
const std::size_t slot_index =
|
||||
static_cast<std::size_t>(ticket - state->ticket_pool.data());
|
||||
state->free_tickets.push_back(slot_index);
|
||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
if (state->submitted_buffers > 0) {
|
||||
--state->submitted_buffers;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (delete_ticket) {
|
||||
delete ticket;
|
||||
}
|
||||
return ToOpenSlResult(status);
|
||||
}
|
||||
|
||||
@@ -149,12 +164,26 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
}
|
||||
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
||||
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
|
||||
std::size_t discarded_count = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (state->stopping) {
|
||||
return opensl_abi::kResultPreconditionsViolated;
|
||||
}
|
||||
++state->generation;
|
||||
state->free_tickets.clear();
|
||||
for (auto& ticket : state->ticket_pool) {
|
||||
if (ticket.in_use) {
|
||||
ticket.in_use = false;
|
||||
ticket.state.reset();
|
||||
++discarded_count;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = state->max_buffers; i > 0; --i) {
|
||||
state->free_tickets.push_back(i - 1);
|
||||
}
|
||||
state->discarded_buffers += discarded_count;
|
||||
state->pending_count.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
const Status status = state->sink->Clear();
|
||||
if (!status.ok()) {
|
||||
@@ -170,11 +199,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
if (state == nullptr || output == nullptr) {
|
||||
return opensl_abi::kResultParameterInvalid;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
output->count = static_cast<Uint32>(std::min<std::size_t>(
|
||||
state->pending.size(), std::numeric_limits<Uint32>::max()));
|
||||
state->pending_count.load(std::memory_order_relaxed),
|
||||
std::numeric_limits<Uint32>::max()));
|
||||
output->index = static_cast<Uint32>(std::min<std::uint64_t>(
|
||||
state->processed_buffers, std::numeric_limits<Uint32>::max()));
|
||||
state->processed_buffers.load(std::memory_order_relaxed),
|
||||
std::numeric_limits<Uint32>::max()));
|
||||
return opensl_abi::kResultSuccess;
|
||||
}
|
||||
|
||||
@@ -201,18 +231,25 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
return;
|
||||
}
|
||||
const std::shared_ptr<State> state = ticket->state;
|
||||
bool delete_ticket = false;
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
}
|
||||
bool notify_worker = false;
|
||||
bool consumed = false;
|
||||
bool discarded = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
const auto found = state->pending.find(ticket->id);
|
||||
if (found != state->pending.end() && found->second == ticket) {
|
||||
state->pending.erase(found);
|
||||
delete_ticket = true;
|
||||
if (ticket->in_use) {
|
||||
ticket->in_use = false;
|
||||
ticket->state.reset();
|
||||
const std::size_t slot_index =
|
||||
static_cast<std::size_t>(ticket - state->ticket_pool.data());
|
||||
state->free_tickets.push_back(slot_index);
|
||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
if (!state->stopping && ticket->generation == state->generation) {
|
||||
++state->processed_buffers;
|
||||
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
|
||||
++state->consumed_buffers;
|
||||
state->consumed_bytes += size_bytes;
|
||||
consumed = true;
|
||||
@@ -227,17 +264,14 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notify_worker) {
|
||||
state->callback_cv.notify_one();
|
||||
}
|
||||
if (delete_ticket) {
|
||||
delete ticket;
|
||||
}
|
||||
if (consumed) {
|
||||
state->NotifyEvent(OpenSlBufferQueueEvent::kConsumed, size_bytes);
|
||||
} else if (discarded) {
|
||||
state->NotifyEvent(OpenSlBufferQueueEvent::kDiscarded, size_bytes);
|
||||
}
|
||||
if (notify_worker) {
|
||||
state->callback_cv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
void NotifyEvent(OpenSlBufferQueueEvent event, std::size_t size_bytes) {
|
||||
@@ -281,8 +315,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
return Status::Error(StatusCode::kUnavailable,
|
||||
"unable to allocate OpenSL callback thread state");
|
||||
}
|
||||
const int result = pthread_create(&callback_thread, nullptr,
|
||||
pthread_attr_t attr;
|
||||
pthread_attr_init(&attr);
|
||||
pthread_attr_setstacksize(&attr, 256 * 1024);
|
||||
const int result = pthread_create(&callback_thread, &attr,
|
||||
&State::CallbackThreadMain, state_holder);
|
||||
pthread_attr_destroy(&attr);
|
||||
if (result != 0) {
|
||||
delete state_holder;
|
||||
return Status::Error(
|
||||
@@ -336,14 +374,16 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
void* const event_context;
|
||||
Handle handle;
|
||||
pthread_t callback_thread{};
|
||||
std::unordered_map<std::uint64_t, ReleaseTicket*> pending;
|
||||
std::vector<ReleaseTicket> ticket_pool;
|
||||
std::vector<std::size_t> free_tickets;
|
||||
std::atomic<std::size_t> pending_count{0};
|
||||
std::deque<CallbackJob> callback_jobs;
|
||||
AndroidSimpleBufferQueueCallback callback = nullptr;
|
||||
void* callback_context = nullptr;
|
||||
Status last_status;
|
||||
std::uint64_t next_id = 1;
|
||||
std::uint64_t generation = 1;
|
||||
std::uint64_t processed_buffers = 0;
|
||||
std::atomic<std::uint64_t> processed_buffers{0};
|
||||
std::uint64_t submitted_buffers = 0;
|
||||
std::uint64_t consumed_buffers = 0;
|
||||
std::uint64_t discarded_buffers = 0;
|
||||
@@ -475,7 +515,8 @@ OpenSlSimpleBufferQueueStats OpenSlSimpleBufferQueueAdapter::GetStats() const {
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
return OpenSlSimpleBufferQueueStats{
|
||||
state->submitted_buffers, state->consumed_buffers,
|
||||
state->discarded_buffers, state->consumed_bytes, state->pending.size()};
|
||||
state->discarded_buffers, state->consumed_bytes,
|
||||
state->pending_count.load(std::memory_order_relaxed)};
|
||||
}
|
||||
|
||||
Status OpenSlSimpleBufferQueueAdapter::last_error() const {
|
||||
@@ -521,10 +562,15 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (!state->pending.empty()) {
|
||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
||||
state->last_status = Status::Error(
|
||||
StatusCode::kPlatformError,
|
||||
"audio sink did not release all borrowed OpenSL buffers");
|
||||
for (auto& ticket : state->ticket_pool) {
|
||||
ticket.in_use = false;
|
||||
ticket.state.reset();
|
||||
}
|
||||
state->pending_count.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
state->shutdown_complete = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
#include "mocktail/audio/sdl_audio_capture.h"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::audio {
|
||||
namespace {
|
||||
|
||||
struct SdlRecordingSubsystemState {
|
||||
std::mutex mutex;
|
||||
std::uint32_t configured_device_id = 0;
|
||||
std::string configured_device_name = "default";
|
||||
std::size_t live_captures = 0;
|
||||
};
|
||||
|
||||
SdlRecordingSubsystemState& RecordingSubsystemState() {
|
||||
static SdlRecordingSubsystemState state;
|
||||
return state;
|
||||
}
|
||||
|
||||
Status SdlError(const char* operation) {
|
||||
std::string message(operation);
|
||||
message.append(": ");
|
||||
const char* detail = SDL_GetError();
|
||||
message.append(detail != nullptr && detail[0] != '\0' ? detail
|
||||
: "unknown SDL error");
|
||||
return Status::Error(StatusCode::kPlatformError, std::move(message));
|
||||
}
|
||||
|
||||
Status InvalidArgument(std::string message) {
|
||||
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
||||
}
|
||||
|
||||
Status FailedPrecondition(std::string message) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition, std::move(message));
|
||||
}
|
||||
|
||||
bool AudioSubsystemInitialized() {
|
||||
return (SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) != 0;
|
||||
}
|
||||
|
||||
SDL_AudioFormat ToSdlFormat(PcmSampleFormat format) {
|
||||
switch (format) {
|
||||
case PcmSampleFormat::kUnsigned8:
|
||||
return SDL_AUDIO_U8;
|
||||
case PcmSampleFormat::kSigned16LittleEndian:
|
||||
return SDL_AUDIO_S16LE;
|
||||
case PcmSampleFormat::kSigned32LittleEndian:
|
||||
return SDL_AUDIO_S32LE;
|
||||
case PcmSampleFormat::kFloat32LittleEndian:
|
||||
return SDL_AUDIO_F32LE;
|
||||
}
|
||||
return SDL_AUDIO_UNKNOWN;
|
||||
}
|
||||
|
||||
Status ResolveConfiguredDevice(std::uint32_t requested_device_id,
|
||||
std::uint32_t* resolved_device_id) {
|
||||
if (resolved_device_id == nullptr) {
|
||||
return InvalidArgument("resolved SDL recording device pointer is null");
|
||||
}
|
||||
if (!AudioSubsystemInitialized()) {
|
||||
return FailedPrecondition(
|
||||
"SDL audio subsystem must be initialized before recording");
|
||||
}
|
||||
SdlRecordingSubsystemState& state = RecordingSubsystemState();
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
*resolved_device_id = requested_device_id == 0
|
||||
? state.configured_device_id
|
||||
: requested_device_id;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RegisterCapture() {
|
||||
if (!AudioSubsystemInitialized()) {
|
||||
return FailedPrecondition(
|
||||
"SDL audio subsystem stopped while opening a capture");
|
||||
}
|
||||
SdlRecordingSubsystemState& state = RecordingSubsystemState();
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
++state.live_captures;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
void ReleaseCapture() {
|
||||
SdlRecordingSubsystemState& state = RecordingSubsystemState();
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
if (state.live_captures != 0) {
|
||||
--state.live_captures;
|
||||
}
|
||||
}
|
||||
|
||||
class SdlAudioCapture final : public AudioCapture {
|
||||
public:
|
||||
SdlAudioCapture(PcmSpec spec, std::size_t frames_per_buffer,
|
||||
std::vector<std::uint8_t> buffer, SDL_AudioStream* stream,
|
||||
AudioCaptureDataCallback callback, void* callback_context)
|
||||
: output_spec_(spec),
|
||||
frames_per_buffer_(frames_per_buffer),
|
||||
buffer_(std::move(buffer)),
|
||||
stream_(stream),
|
||||
callback_(callback),
|
||||
callback_context_(callback_context) {}
|
||||
|
||||
~SdlAudioCapture() override { Shutdown(); }
|
||||
|
||||
const PcmSpec& output_spec() const override { return output_spec_; }
|
||||
void* buffer_data() override { return buffer_.data(); }
|
||||
std::size_t buffer_size_bytes() const override { return buffer_.size(); }
|
||||
std::size_t frames_per_buffer() const override {
|
||||
return frames_per_buffer_;
|
||||
}
|
||||
|
||||
void AdoptStream(SDL_AudioStream* stream) { stream_ = stream; }
|
||||
|
||||
void MarkRegistered() { registered_ = true; }
|
||||
|
||||
Status Start() override {
|
||||
std::lock_guard<std::mutex> lock(operation_mutex_);
|
||||
if (stream_ == nullptr || stopping_.load(std::memory_order_acquire)) {
|
||||
return FailedPrecondition("SDL audio capture is shut down");
|
||||
}
|
||||
if (running_.load(std::memory_order_acquire)) {
|
||||
return Status::Ok();
|
||||
}
|
||||
if (!SDL_ResumeAudioStreamDevice(stream_)) {
|
||||
return SdlError("SDL_ResumeAudioStreamDevice");
|
||||
}
|
||||
running_.store(true, std::memory_order_release);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status Stop() override {
|
||||
std::lock_guard<std::mutex> lock(operation_mutex_);
|
||||
if (stream_ == nullptr || stopping_.load(std::memory_order_acquire)) {
|
||||
return Status::Ok();
|
||||
}
|
||||
running_.store(false, std::memory_order_release);
|
||||
if (!SDL_PauseAudioStreamDevice(stream_)) {
|
||||
return SdlError("SDL_PauseAudioStreamDevice");
|
||||
}
|
||||
if (!SDL_ClearAudioStream(stream_)) {
|
||||
return SdlError("SDL_ClearAudioStream");
|
||||
}
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
void Shutdown() override {
|
||||
std::lock_guard<std::mutex> lock(operation_mutex_);
|
||||
if (stream_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
stopping_.store(true, std::memory_order_release);
|
||||
running_.store(false, std::memory_order_release);
|
||||
(void)SDL_PauseAudioStreamDevice(stream_);
|
||||
SDL_DestroyAudioStream(stream_);
|
||||
stream_ = nullptr;
|
||||
if (registered_) {
|
||||
registered_ = false;
|
||||
ReleaseCapture();
|
||||
}
|
||||
}
|
||||
|
||||
static void SDLCALL OnAudioAvailable(void* userdata,
|
||||
SDL_AudioStream* stream,
|
||||
int /*additional_amount*/,
|
||||
int /*total_amount*/) {
|
||||
auto* capture = static_cast<SdlAudioCapture*>(userdata);
|
||||
if (capture == nullptr || stream == nullptr ||
|
||||
capture->stopping_.load(std::memory_order_acquire) ||
|
||||
!capture->running_.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
const int frame_bytes = static_cast<int>(capture->buffer_.size());
|
||||
while (!capture->stopping_.load(std::memory_order_acquire) &&
|
||||
capture->running_.load(std::memory_order_acquire)) {
|
||||
const int available = SDL_GetAudioStreamAvailable(stream);
|
||||
if (available < frame_bytes) {
|
||||
return;
|
||||
}
|
||||
const int read = SDL_GetAudioStreamData(stream, capture->buffer_.data(),
|
||||
frame_bytes);
|
||||
if (read != frame_bytes) {
|
||||
return;
|
||||
}
|
||||
capture->callback_(capture->callback_context_,
|
||||
static_cast<std::size_t>(read));
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
PcmSpec output_spec_;
|
||||
std::size_t frames_per_buffer_ = 0;
|
||||
std::vector<std::uint8_t> buffer_;
|
||||
SDL_AudioStream* stream_ = nullptr;
|
||||
AudioCaptureDataCallback callback_ = nullptr;
|
||||
void* callback_context_ = nullptr;
|
||||
std::mutex operation_mutex_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> stopping_{false};
|
||||
bool registered_ = false;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Status ListSdlRecordingDevices(std::vector<SdlRecordingDevice>* devices) {
|
||||
if (devices == nullptr) {
|
||||
return InvalidArgument("SDL recording device output pointer is null");
|
||||
}
|
||||
devices->clear();
|
||||
if (!AudioSubsystemInitialized()) {
|
||||
return FailedPrecondition(
|
||||
"SDL audio subsystem must be initialized before device enumeration");
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID* ids = SDL_GetAudioRecordingDevices(&count);
|
||||
if (ids == nullptr) {
|
||||
return SdlError("SDL_GetAudioRecordingDevices");
|
||||
}
|
||||
devices->reserve(count > 0 ? static_cast<std::size_t>(count) : 0U);
|
||||
for (int index = 0; index < count; ++index) {
|
||||
const char* name = SDL_GetAudioDeviceName(ids[index]);
|
||||
if (name == nullptr || name[0] == '\0') {
|
||||
SDL_free(ids);
|
||||
devices->clear();
|
||||
return SdlError("SDL_GetAudioDeviceName(recording)");
|
||||
}
|
||||
devices->push_back(
|
||||
{static_cast<std::uint32_t>(ids[index]), std::string(name)});
|
||||
}
|
||||
SDL_free(ids);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status ResolveSdlRecordingDevice(
|
||||
std::string_view requested,
|
||||
const std::vector<SdlRecordingDevice>& available_devices,
|
||||
std::uint32_t* recording_device_id, std::string* resolved_name) {
|
||||
if (recording_device_id == nullptr || resolved_name == nullptr) {
|
||||
return InvalidArgument("SDL recording selection output pointer is null");
|
||||
}
|
||||
*recording_device_id = 0;
|
||||
resolved_name->clear();
|
||||
if (requested.empty() || requested.size() > 512U) {
|
||||
return InvalidArgument(
|
||||
"audio input device must be `default`, `id:<number>`, or a bounded "
|
||||
"device name");
|
||||
}
|
||||
for (const unsigned char character : requested) {
|
||||
if (character < 0x20U || character == 0x7fU) {
|
||||
return InvalidArgument(
|
||||
"audio input device must not contain control bytes");
|
||||
}
|
||||
}
|
||||
if (requested == "default") {
|
||||
*resolved_name = "default";
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
if (requested.rfind("id:", 0) == 0) {
|
||||
std::uint32_t parsed = 0;
|
||||
const std::string_view value = requested.substr(3);
|
||||
const auto parse =
|
||||
std::from_chars(value.data(), value.data() + value.size(), parsed);
|
||||
if (value.empty() || parse.ec != std::errc{} ||
|
||||
parse.ptr != value.data() + value.size() || parsed == 0) {
|
||||
return InvalidArgument("audio input device ID is invalid");
|
||||
}
|
||||
const auto found =
|
||||
std::find_if(available_devices.begin(), available_devices.end(),
|
||||
[parsed](const SdlRecordingDevice& device) {
|
||||
return device.id == parsed;
|
||||
});
|
||||
if (found == available_devices.end()) {
|
||||
return Status::Error(StatusCode::kUnavailable,
|
||||
"configured audio input device ID is unavailable");
|
||||
}
|
||||
*recording_device_id = found->id;
|
||||
*resolved_name = found->name;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
const SdlRecordingDevice* match = nullptr;
|
||||
for (const SdlRecordingDevice& device : available_devices) {
|
||||
if (device.name != requested) {
|
||||
continue;
|
||||
}
|
||||
if (match != nullptr) {
|
||||
return InvalidArgument(
|
||||
"audio input device name is ambiguous; use its printed `id:` "
|
||||
"selector");
|
||||
}
|
||||
match = &device;
|
||||
}
|
||||
if (match == nullptr) {
|
||||
return Status::Error(StatusCode::kUnavailable,
|
||||
"configured audio input device is unavailable: " +
|
||||
std::string(requested));
|
||||
}
|
||||
*recording_device_id = match->id;
|
||||
*resolved_name = match->name;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status ConfigureSdlRecordingDevice(
|
||||
std::string_view requested,
|
||||
std::vector<SdlRecordingDevice>* available_devices,
|
||||
std::string* resolved_name) {
|
||||
if (available_devices == nullptr || resolved_name == nullptr) {
|
||||
return InvalidArgument("SDL recording configuration output is null");
|
||||
}
|
||||
Status status = ListSdlRecordingDevices(available_devices);
|
||||
if (!status.ok() && requested != "default") {
|
||||
return status;
|
||||
}
|
||||
if (!status.ok()) {
|
||||
available_devices->clear();
|
||||
}
|
||||
std::uint32_t recording_device_id = 0;
|
||||
status = ResolveSdlRecordingDevice(requested, *available_devices,
|
||||
&recording_device_id, resolved_name);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
|
||||
SdlRecordingSubsystemState& state = RecordingSubsystemState();
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
if (state.live_captures != 0) {
|
||||
return FailedPrecondition(
|
||||
"SDL recording device must be configured before opening a capture");
|
||||
}
|
||||
state.configured_device_id = recording_device_id;
|
||||
state.configured_device_name = *resolved_name;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status GetConfiguredSdlRecordingDevice(std::uint32_t* recording_device_id,
|
||||
std::string* resolved_name) {
|
||||
if (recording_device_id == nullptr || resolved_name == nullptr) {
|
||||
return InvalidArgument(
|
||||
"configured SDL recording device output pointer is null");
|
||||
}
|
||||
if (!AudioSubsystemInitialized()) {
|
||||
return FailedPrecondition(
|
||||
"SDL audio subsystem must be initialized before reading input route");
|
||||
}
|
||||
SdlRecordingSubsystemState& state = RecordingSubsystemState();
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
*recording_device_id = state.configured_device_id;
|
||||
*resolved_name = state.configured_device_name;
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status CreateSdlAudioCapture(const SdlAudioCaptureOptions& options,
|
||||
std::unique_ptr<AudioCapture>* capture) {
|
||||
if (capture == nullptr) {
|
||||
return InvalidArgument("audio capture output pointer is null");
|
||||
}
|
||||
capture->reset();
|
||||
Status status = ValidatePcmSpec(options.output_spec);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
if (options.data_callback == nullptr || options.frame_duration_ms == 0 ||
|
||||
options.frame_duration_ms > 1000 ||
|
||||
(static_cast<std::uint64_t>(options.output_spec.sample_rate_hz) *
|
||||
options.frame_duration_ms) %
|
||||
1000U !=
|
||||
0) {
|
||||
return InvalidArgument("SDL capture requires a valid fixed-frame callback");
|
||||
}
|
||||
|
||||
const std::uint64_t frames =
|
||||
static_cast<std::uint64_t>(options.output_spec.sample_rate_hz) *
|
||||
options.frame_duration_ms / 1000U;
|
||||
const std::uint64_t buffer_bytes =
|
||||
frames * BytesPerFrame(options.output_spec);
|
||||
if (frames == 0 || buffer_bytes == 0 ||
|
||||
buffer_bytes > static_cast<std::uint64_t>(
|
||||
std::numeric_limits<int>::max())) {
|
||||
return InvalidArgument("SDL capture frame size is invalid");
|
||||
}
|
||||
|
||||
std::uint32_t recording_device_id = 0;
|
||||
status = ResolveConfiguredDevice(options.recording_device_id,
|
||||
&recording_device_id);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
const SDL_AudioDeviceID device =
|
||||
recording_device_id == 0
|
||||
? SDL_AUDIO_DEVICE_DEFAULT_RECORDING
|
||||
: static_cast<SDL_AudioDeviceID>(recording_device_id);
|
||||
SDL_AudioSpec spec{};
|
||||
spec.format = ToSdlFormat(options.output_spec.format);
|
||||
spec.channels = options.output_spec.channels;
|
||||
spec.freq = options.output_spec.sample_rate_hz;
|
||||
|
||||
std::vector<std::uint8_t> buffer(static_cast<std::size_t>(buffer_bytes));
|
||||
auto* implementation = new (std::nothrow) SdlAudioCapture(
|
||||
options.output_spec, static_cast<std::size_t>(frames), std::move(buffer),
|
||||
nullptr, options.data_callback, options.data_context);
|
||||
if (implementation == nullptr) {
|
||||
return Status::Error(StatusCode::kUnavailable,
|
||||
"unable to allocate SDL audio capture");
|
||||
}
|
||||
SDL_AudioStream* stream = SDL_OpenAudioDeviceStream(
|
||||
device, &spec, &SdlAudioCapture::OnAudioAvailable, implementation);
|
||||
if (stream == nullptr) {
|
||||
delete implementation;
|
||||
return SdlError("SDL_OpenAudioDeviceStream(recording)");
|
||||
}
|
||||
|
||||
implementation->AdoptStream(stream);
|
||||
status = RegisterCapture();
|
||||
if (!status.ok()) {
|
||||
delete implementation;
|
||||
return status;
|
||||
}
|
||||
implementation->MarkRegistered();
|
||||
capture->reset(implementation);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status PrepareSdlAudioCaptureSubsystemShutdown() {
|
||||
SdlRecordingSubsystemState& state = RecordingSubsystemState();
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
if (state.live_captures != 0) {
|
||||
return FailedPrecondition(
|
||||
"SDL audio shutdown requires every capture to close");
|
||||
}
|
||||
state.configured_device_id = 0;
|
||||
state.configured_device_name = "default";
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
} // namespace mocktail::audio
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "mocktail/audio/sdl_audio_sink.h"
|
||||
|
||||
#include "mocktail/audio/sdl_audio_capture.h"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -132,11 +134,16 @@ class SdlAudioSink final : public AudioSink {
|
||||
public:
|
||||
SdlAudioSink(PcmSpec spec, SDL_AudioStream* stream,
|
||||
SDL_AudioDeviceID logical_device_id,
|
||||
std::uint32_t physical_device_id)
|
||||
std::uint32_t physical_device_id,
|
||||
bool (*data_needed_callback)(void*, const void**,
|
||||
std::size_t*),
|
||||
void* data_needed_context)
|
||||
: source_spec_(spec),
|
||||
stream_(stream),
|
||||
logical_device_id_(logical_device_id),
|
||||
physical_device_id_(physical_device_id) {}
|
||||
physical_device_id_(physical_device_id),
|
||||
data_needed_callback_(data_needed_callback),
|
||||
data_needed_context_(data_needed_context) {}
|
||||
|
||||
~SdlAudioSink() override { Shutdown(); }
|
||||
|
||||
@@ -264,7 +271,14 @@ class SdlAudioSink final : public AudioSink {
|
||||
return Status::Ok();
|
||||
}
|
||||
switching_device_ = true;
|
||||
shutdown_cv_.wait(lock, [this] { return active_calls_ == 0; });
|
||||
shutdown_cv_.wait(lock,
|
||||
[this] { return active_calls_ == 0 || shutting_down_; });
|
||||
if (shutting_down_ || stream_ == nullptr || logical_device_id_ == 0) {
|
||||
switching_device_ = false;
|
||||
lock.unlock();
|
||||
shutdown_cv_.notify_all();
|
||||
return FailedPrecondition("audio sink is shut down");
|
||||
}
|
||||
|
||||
const bool was_paused = SDL_AudioDevicePaused(logical_device_id_);
|
||||
const SDL_AudioDeviceID replacement =
|
||||
@@ -322,7 +336,9 @@ class SdlAudioSink final : public AudioSink {
|
||||
return;
|
||||
}
|
||||
shutting_down_ = true;
|
||||
shutdown_cv_.wait(lock, [this] { return active_calls_ == 0; });
|
||||
shutdown_cv_.notify_all();
|
||||
shutdown_cv_.wait(
|
||||
lock, [this] { return active_calls_ == 0 && !switching_device_; });
|
||||
stream = std::exchange(stream_, nullptr);
|
||||
logical_device_id = std::exchange(logical_device_id_, 0);
|
||||
}
|
||||
@@ -345,6 +361,33 @@ class SdlAudioSink final : public AudioSink {
|
||||
shutdown_cv_.notify_all();
|
||||
}
|
||||
|
||||
static void SDLCALL OnDataNeeded(void* userdata, SDL_AudioStream* stream,
|
||||
int additional_amount, int) {
|
||||
auto* sink = static_cast<SdlAudioSink*>(userdata);
|
||||
if (sink == nullptr || sink->data_needed_callback_ == nullptr ||
|
||||
additional_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
constexpr std::size_t kMaximumBuffersPerCallback = 32;
|
||||
std::size_t supplied = 0;
|
||||
for (std::size_t index = 0;
|
||||
supplied < static_cast<std::size_t>(additional_amount) &&
|
||||
index < kMaximumBuffersPerCallback;
|
||||
++index) {
|
||||
const void* data = nullptr;
|
||||
std::size_t size_bytes = 0;
|
||||
if (!sink->data_needed_callback_(sink->data_needed_context_, &data,
|
||||
&size_bytes) ||
|
||||
data == nullptr || size_bytes == 0 ||
|
||||
size_bytes >
|
||||
static_cast<std::size_t>(std::numeric_limits<int>::max()) ||
|
||||
!SDL_PutAudioStreamData(stream, data, static_cast<int>(size_bytes))) {
|
||||
break;
|
||||
}
|
||||
supplied += size_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Operation>
|
||||
Status WithStream(const char* name, Operation operation) {
|
||||
@@ -371,11 +414,17 @@ class SdlAudioSink final : public AudioSink {
|
||||
}
|
||||
|
||||
void EndCall() const {
|
||||
bool notify = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
--active_calls_;
|
||||
if (__builtin_expect(shutting_down_ || switching_device_, 0)) {
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
if (notify) {
|
||||
shutdown_cv_.notify_all();
|
||||
}
|
||||
shutdown_cv_.notify_all();
|
||||
}
|
||||
|
||||
const PcmSpec source_spec_;
|
||||
@@ -385,6 +434,8 @@ class SdlAudioSink final : public AudioSink {
|
||||
SDL_AudioStream* stream_ = nullptr;
|
||||
SDL_AudioDeviceID logical_device_id_ = 0;
|
||||
std::uint32_t physical_device_id_ = 0;
|
||||
bool (*data_needed_callback_)(void*, const void**, std::size_t*) = nullptr;
|
||||
void* data_needed_context_ = nullptr;
|
||||
mutable bool switching_device_ = false;
|
||||
bool shutting_down_ = false;
|
||||
bool shutdown_complete_ = false;
|
||||
@@ -614,6 +665,10 @@ Status ShutdownSdlAudioSubsystem() {
|
||||
return Status::Error(StatusCode::kFailedPrecondition,
|
||||
"SDL audio shutdown requires every sink to close");
|
||||
}
|
||||
const Status capture_status = PrepareSdlAudioCaptureSubsystemShutdown();
|
||||
if (!capture_status.ok()) {
|
||||
return capture_status;
|
||||
}
|
||||
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||
state.configured_playback_device_id = 0;
|
||||
state.configured_playback_device_name = "default";
|
||||
@@ -672,13 +727,21 @@ Status CreateSdlAudioSink(const SdlAudioSinkOptions& options,
|
||||
}
|
||||
|
||||
auto* implementation = new (std::nothrow) SdlAudioSink(
|
||||
options.source_spec, stream, logical_device, playback_device_id);
|
||||
options.source_spec, stream, logical_device, playback_device_id,
|
||||
options.data_needed_callback, options.data_needed_context);
|
||||
if (implementation == nullptr) {
|
||||
SDL_DestroyAudioStream(stream);
|
||||
SDL_CloseAudioDevice(logical_device);
|
||||
return Status::Error(StatusCode::kUnavailable,
|
||||
"unable to allocate SDL audio sink");
|
||||
}
|
||||
if (options.data_needed_callback != nullptr &&
|
||||
!SDL_SetAudioStreamGetCallback(stream, &SdlAudioSink::OnDataNeeded,
|
||||
implementation)) {
|
||||
status = SdlError("SDL_SetAudioStreamGetCallback");
|
||||
delete implementation;
|
||||
return status;
|
||||
}
|
||||
status = RegisterSdlAudioSink(implementation);
|
||||
if (!status.ok()) {
|
||||
delete implementation;
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
#include "mocktail/audio/webrtc_jni_audio_bridge.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
#include "mocktail/audio/sdl_audio_capture.h"
|
||||
#include "mocktail/audio/sdl_audio_sink.h"
|
||||
|
||||
namespace mocktail::audio {
|
||||
namespace {
|
||||
|
||||
struct CaptureSession {
|
||||
const void* identity = nullptr;
|
||||
jnivm::WebRtcAudioRecordDataCallback data_callback = nullptr;
|
||||
void* data_context = nullptr;
|
||||
std::unique_ptr<AudioCapture> capture;
|
||||
std::shared_ptr<std::atomic<bool>> microphone_muted;
|
||||
};
|
||||
|
||||
struct PlayoutSession {
|
||||
const void* identity = nullptr;
|
||||
jnivm::WebRtcAudioTrackDataCallback data_callback = nullptr;
|
||||
void* data_context = nullptr;
|
||||
std::unique_ptr<AudioSink> sink;
|
||||
std::vector<std::uint8_t> buffer;
|
||||
std::size_t frames_per_buffer = 0;
|
||||
int buffer_size_frames = 0;
|
||||
std::mutex operation_mutex;
|
||||
std::mutex mutex;
|
||||
bool running = false;
|
||||
bool stop_requested = false;
|
||||
bool in_data_callback = false;
|
||||
std::thread::id data_callback_thread;
|
||||
};
|
||||
|
||||
struct WebRtcJniAudioBridgeContext {
|
||||
std::mutex mutex;
|
||||
std::unordered_set<const void*> initialized_managers;
|
||||
std::shared_ptr<std::atomic<bool>> microphone_muted =
|
||||
std::make_shared<std::atomic<bool>>(false);
|
||||
std::unordered_map<const void*, std::shared_ptr<CaptureSession>>
|
||||
capture_sessions;
|
||||
std::unordered_map<const void*, std::shared_ptr<PlayoutSession>>
|
||||
playout_sessions;
|
||||
bool recording_enabled = true;
|
||||
bool shutting_down = false;
|
||||
};
|
||||
|
||||
bool GetManagerParameters(void* opaque_context,
|
||||
jnivm::WebRtcAudioManagerParameters* parameters) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || parameters == nullptr) return false;
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (context->shutting_down) return false;
|
||||
// SDL converts between this WebRTC-facing format and the actual device.
|
||||
// Match WebRtcAudioManager's default mono input/output and 10 ms frames.
|
||||
// Advertising the format never opens a capture device, including when the
|
||||
// microphone is disabled. Recording init remains the permission boundary.
|
||||
*parameters = {};
|
||||
parameters->sample_rate_hz = 48000;
|
||||
parameters->output_channels = 1;
|
||||
parameters->input_channels = 1;
|
||||
parameters->output_buffer_size_frames = 480;
|
||||
parameters->input_buffer_size_frames = 480;
|
||||
// Android hardware effects, low-latency paths and AAudio are not emulated;
|
||||
// WebRTC must retain its software processing and Java audio transport.
|
||||
return true;
|
||||
}
|
||||
|
||||
bool InitManager(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr) return false;
|
||||
bool inserted = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (context->shutting_down) return false;
|
||||
inserted = context->initialized_managers.insert(identity).second;
|
||||
}
|
||||
if (inserted) {
|
||||
std::cout << " [mocktail][audio] WebRTC audio manager initialized\n"
|
||||
<< std::flush;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void DisposeManager(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr) return;
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (context->initialized_managers.erase(identity) != 0) {
|
||||
std::cout << " [mocktail][audio] WebRTC audio manager disposed\n"
|
||||
<< std::flush;
|
||||
}
|
||||
// Record/track objects own their streams and stop them independently.
|
||||
}
|
||||
|
||||
void SetMicrophoneMute(void* opaque_context, bool muted) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr) return;
|
||||
context->microphone_muted->store(muted, std::memory_order_release);
|
||||
}
|
||||
|
||||
void OnCapturedAudio(void* opaque_session, std::size_t size_bytes) {
|
||||
auto* session = static_cast<CaptureSession*>(opaque_session);
|
||||
if (session != nullptr && session->data_callback != nullptr) {
|
||||
if (session->microphone_muted != nullptr &&
|
||||
session->microphone_muted->load(std::memory_order_acquire)) {
|
||||
if (session->capture == nullptr ||
|
||||
size_bytes > session->capture->buffer_size_bytes()) {
|
||||
return;
|
||||
}
|
||||
std::memset(session->capture->buffer_data(), 0, size_bytes);
|
||||
}
|
||||
session->data_callback(session->data_context, session->identity,
|
||||
size_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
void CloseCaptureSession(const std::shared_ptr<CaptureSession>& session) {
|
||||
if (session == nullptr || session->capture == nullptr) {
|
||||
return;
|
||||
}
|
||||
(void)session->capture->Stop();
|
||||
session->capture->Shutdown();
|
||||
}
|
||||
|
||||
int Init(void* opaque_context, const void* identity, int sample_rate_hz,
|
||||
int channels, jnivm::WebRtcAudioRecordDataCallback data_callback,
|
||||
void* data_context, void** direct_buffer,
|
||||
std::size_t* direct_buffer_capacity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr || data_callback == nullptr ||
|
||||
direct_buffer == nullptr || direct_buffer_capacity == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
*direct_buffer = nullptr;
|
||||
*direct_buffer_capacity = 0;
|
||||
|
||||
std::shared_ptr<CaptureSession> previous;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (!context->recording_enabled || context->shutting_down) {
|
||||
return -1;
|
||||
}
|
||||
const auto found = context->capture_sessions.find(identity);
|
||||
if (found != context->capture_sessions.end()) {
|
||||
previous = std::move(found->second);
|
||||
context->capture_sessions.erase(found);
|
||||
}
|
||||
}
|
||||
CloseCaptureSession(previous);
|
||||
|
||||
auto session = std::shared_ptr<CaptureSession>(
|
||||
new (std::nothrow) CaptureSession());
|
||||
if (session == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
session->identity = identity;
|
||||
session->data_callback = data_callback;
|
||||
session->data_context = data_context;
|
||||
session->microphone_muted = context->microphone_muted;
|
||||
|
||||
SdlAudioCaptureOptions options;
|
||||
options.output_spec.sample_rate_hz = sample_rate_hz;
|
||||
options.output_spec.channels =
|
||||
channels > 0 && channels <= 255 ? static_cast<std::uint8_t>(channels) : 0;
|
||||
options.output_spec.format = PcmSampleFormat::kSigned16LittleEndian;
|
||||
options.frame_duration_ms = 10;
|
||||
options.data_callback = &OnCapturedAudio;
|
||||
options.data_context = session.get();
|
||||
const Status status = CreateSdlAudioCapture(options, &session->capture);
|
||||
if (!status.ok()) {
|
||||
std::cerr << " [mocktail][audio] WebRTC microphone init_failed reason="
|
||||
<< status.message() << '\n';
|
||||
return -1;
|
||||
}
|
||||
if (session->capture->frames_per_buffer() >
|
||||
static_cast<std::size_t>(std::numeric_limits<int>::max())) {
|
||||
session->capture->Shutdown();
|
||||
return -1;
|
||||
}
|
||||
*direct_buffer = session->capture->buffer_data();
|
||||
*direct_buffer_capacity = session->capture->buffer_size_bytes();
|
||||
const int frames =
|
||||
static_cast<int>(session->capture->frames_per_buffer());
|
||||
bool accepted = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (context->recording_enabled && !context->shutting_down) {
|
||||
context->capture_sessions[identity] = session;
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
if (!accepted) {
|
||||
*direct_buffer = nullptr;
|
||||
*direct_buffer_capacity = 0;
|
||||
CloseCaptureSession(session);
|
||||
return -1;
|
||||
}
|
||||
std::cout << " [mocktail][audio] WebRTC microphone initialized "
|
||||
<< "sample_rate_hz=" << sample_rate_hz
|
||||
<< " channels=" << channels << " frames_per_buffer=" << frames
|
||||
<< '\n'
|
||||
<< std::flush;
|
||||
return frames;
|
||||
}
|
||||
|
||||
bool Start(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr) {
|
||||
return false;
|
||||
}
|
||||
std::shared_ptr<CaptureSession> session;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (!context->recording_enabled || context->shutting_down) {
|
||||
return false;
|
||||
}
|
||||
const auto found = context->capture_sessions.find(identity);
|
||||
if (found != context->capture_sessions.end()) {
|
||||
session = found->second;
|
||||
}
|
||||
}
|
||||
if (session == nullptr || session->capture == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const Status status = session->capture->Start();
|
||||
if (!status.ok()) {
|
||||
std::cerr << " [mocktail][audio] WebRTC microphone start_failed reason="
|
||||
<< status.message() << '\n';
|
||||
return false;
|
||||
}
|
||||
std::cout << " [mocktail][audio] WebRTC microphone recording\n"
|
||||
<< std::flush;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Stop(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr) {
|
||||
return false;
|
||||
}
|
||||
std::shared_ptr<CaptureSession> session;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
const auto found = context->capture_sessions.find(identity);
|
||||
if (found == context->capture_sessions.end()) {
|
||||
return true;
|
||||
}
|
||||
session = std::move(found->second);
|
||||
context->capture_sessions.erase(found);
|
||||
}
|
||||
CloseCaptureSession(session);
|
||||
std::cout << " [mocktail][audio] WebRTC microphone stopped\n"
|
||||
<< std::flush;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Close(void* opaque_context, const void* identity) {
|
||||
(void)Stop(opaque_context, identity);
|
||||
}
|
||||
|
||||
bool OnPlayoutDataNeeded(void* opaque_session, const void** data,
|
||||
std::size_t* size_bytes) {
|
||||
auto* session = static_cast<PlayoutSession*>(opaque_session);
|
||||
if (data == nullptr || size_bytes == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*data = nullptr;
|
||||
*size_bytes = 0;
|
||||
if (session == nullptr || session->buffer.empty()) {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
if (!session->running || session->stop_requested) {
|
||||
return false;
|
||||
}
|
||||
session->in_data_callback = true;
|
||||
session->data_callback_thread = std::this_thread::get_id();
|
||||
}
|
||||
|
||||
session->data_callback(session->data_context, session->identity,
|
||||
session->buffer.size());
|
||||
|
||||
bool ready = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
ready = session->running && !session->stop_requested;
|
||||
session->in_data_callback = false;
|
||||
session->data_callback_thread = {};
|
||||
}
|
||||
if (ready) {
|
||||
*data = session->buffer.data();
|
||||
*size_bytes = session->buffer.size();
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
void ClosePlayoutSession(const std::shared_ptr<PlayoutSession>& session) {
|
||||
if (session == nullptr) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> operation_lock(session->operation_mutex);
|
||||
bool called_from_callback = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->running = false;
|
||||
session->stop_requested = true;
|
||||
called_from_callback =
|
||||
session->in_data_callback &&
|
||||
session->data_callback_thread == std::this_thread::get_id();
|
||||
}
|
||||
// SDL does not permit destroying a stream from its own get callback. The
|
||||
// session stays in the context and is closed by re-init or bridge shutdown.
|
||||
if (called_from_callback || session->sink == nullptr) {
|
||||
return;
|
||||
}
|
||||
(void)session->sink->Pause();
|
||||
(void)session->sink->Clear();
|
||||
session->sink->Shutdown();
|
||||
session->sink.reset();
|
||||
}
|
||||
|
||||
int InitPlayout(void* opaque_context, const void* identity,
|
||||
int sample_rate_hz, int channels, double buffer_size_factor,
|
||||
jnivm::WebRtcAudioTrackDataCallback data_callback,
|
||||
void* data_context, void** direct_buffer,
|
||||
std::size_t* direct_buffer_capacity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr || data_callback == nullptr ||
|
||||
direct_buffer == nullptr || direct_buffer_capacity == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
*direct_buffer = nullptr;
|
||||
*direct_buffer_capacity = 0;
|
||||
if (!std::isfinite(buffer_size_factor) || buffer_size_factor <= 0.0 ||
|
||||
buffer_size_factor > 16.0 || (channels != 1 && channels != 2) ||
|
||||
sample_rate_hz <= 0 || sample_rate_hz % 100 != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const PcmSpec spec{sample_rate_hz, static_cast<std::uint8_t>(channels),
|
||||
PcmSampleFormat::kSigned16LittleEndian};
|
||||
Status status = ValidatePcmSpec(spec);
|
||||
if (!status.ok()) {
|
||||
return -1;
|
||||
}
|
||||
const std::size_t frames_per_buffer =
|
||||
static_cast<std::size_t>(sample_rate_hz / 100);
|
||||
const std::size_t bytes_per_frame = BytesPerFrame(spec);
|
||||
if (frames_per_buffer >
|
||||
static_cast<std::size_t>(std::numeric_limits<int>::max()) /
|
||||
bytes_per_frame) {
|
||||
return -1;
|
||||
}
|
||||
const std::size_t callback_bytes = frames_per_buffer * bytes_per_frame;
|
||||
const double scaled_frames =
|
||||
std::ceil(static_cast<double>(frames_per_buffer) * buffer_size_factor);
|
||||
if (scaled_frames > static_cast<double>(std::numeric_limits<int>::max())) {
|
||||
return -1;
|
||||
}
|
||||
const std::size_t buffer_size_frames = std::max(
|
||||
frames_per_buffer, static_cast<std::size_t>(scaled_frames));
|
||||
if (buffer_size_frames >
|
||||
static_cast<std::size_t>(std::numeric_limits<int>::max()) /
|
||||
bytes_per_frame) {
|
||||
return -1;
|
||||
}
|
||||
const std::size_t buffer_size_bytes = buffer_size_frames * bytes_per_frame;
|
||||
|
||||
std::shared_ptr<PlayoutSession> previous;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (context->shutting_down) {
|
||||
return -1;
|
||||
}
|
||||
const auto found = context->playout_sessions.find(identity);
|
||||
if (found != context->playout_sessions.end()) {
|
||||
previous = std::move(found->second);
|
||||
context->playout_sessions.erase(found);
|
||||
}
|
||||
}
|
||||
ClosePlayoutSession(previous);
|
||||
|
||||
auto session = std::shared_ptr<PlayoutSession>(
|
||||
new (std::nothrow) PlayoutSession());
|
||||
if (session == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
session->identity = identity;
|
||||
session->data_callback = data_callback;
|
||||
session->data_context = data_context;
|
||||
session->frames_per_buffer = frames_per_buffer;
|
||||
session->buffer_size_frames = static_cast<int>(buffer_size_frames);
|
||||
session->buffer.resize(callback_bytes);
|
||||
|
||||
SdlAudioSinkOptions options;
|
||||
options.source_spec = spec;
|
||||
options.start_paused = true;
|
||||
options.data_needed_callback = &OnPlayoutDataNeeded;
|
||||
options.data_needed_context = session.get();
|
||||
status = CreateSdlAudioSink(options, &session->sink);
|
||||
if (!status.ok()) {
|
||||
std::cerr << " [mocktail][audio] WebRTC playout init_failed reason="
|
||||
<< status.message() << '\n';
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool accepted = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (!context->shutting_down) {
|
||||
context->playout_sessions[identity] = session;
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
if (!accepted) {
|
||||
ClosePlayoutSession(session);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*direct_buffer = session->buffer.data();
|
||||
*direct_buffer_capacity = session->buffer.size();
|
||||
std::cout << " [mocktail][audio] WebRTC playout initialized "
|
||||
<< "sample_rate_hz=" << sample_rate_hz
|
||||
<< " channels=" << channels
|
||||
<< " frames_per_buffer=" << frames_per_buffer << '\n'
|
||||
<< std::flush;
|
||||
return static_cast<int>(buffer_size_bytes);
|
||||
}
|
||||
|
||||
int PlayoutBufferSizeFrames(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
std::shared_ptr<PlayoutSession> session;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
const auto found = context->playout_sessions.find(identity);
|
||||
if (found != context->playout_sessions.end()) {
|
||||
session = found->second;
|
||||
}
|
||||
}
|
||||
return session != nullptr ? session->buffer_size_frames : 0;
|
||||
}
|
||||
|
||||
bool StartPlayout(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr) {
|
||||
return false;
|
||||
}
|
||||
std::shared_ptr<PlayoutSession> session;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
if (context->shutting_down) {
|
||||
return false;
|
||||
}
|
||||
const auto found = context->playout_sessions.find(identity);
|
||||
if (found != context->playout_sessions.end()) {
|
||||
session = found->second;
|
||||
}
|
||||
}
|
||||
if (session == nullptr || session->sink == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> operation_lock(session->operation_mutex);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
if (session->running) {
|
||||
return true;
|
||||
}
|
||||
if (session->stop_requested) {
|
||||
return false;
|
||||
}
|
||||
session->running = true;
|
||||
}
|
||||
const Status status = session->sink->Resume();
|
||||
if (!status.ok()) {
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->running = false;
|
||||
std::cerr << " [mocktail][audio] WebRTC playout start_failed reason="
|
||||
<< status.message() << '\n';
|
||||
return false;
|
||||
}
|
||||
std::cout << " [mocktail][audio] WebRTC playout playing\n" << std::flush;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StopPlayout(void* opaque_context, const void* identity) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr || identity == nullptr) {
|
||||
return false;
|
||||
}
|
||||
std::shared_ptr<PlayoutSession> session;
|
||||
bool called_from_callback = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
const auto found = context->playout_sessions.find(identity);
|
||||
if (found == context->playout_sessions.end()) {
|
||||
return true;
|
||||
}
|
||||
session = found->second;
|
||||
{
|
||||
std::lock_guard<std::mutex> session_lock(session->mutex);
|
||||
called_from_callback =
|
||||
session->in_data_callback &&
|
||||
session->data_callback_thread == std::this_thread::get_id();
|
||||
}
|
||||
if (!called_from_callback) {
|
||||
context->playout_sessions.erase(found);
|
||||
}
|
||||
}
|
||||
ClosePlayoutSession(session);
|
||||
std::cout << " [mocktail][audio] WebRTC playout stopped\n" << std::flush;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClosePlayout(void* opaque_context, const void* identity) {
|
||||
(void)StopPlayout(opaque_context, identity);
|
||||
}
|
||||
|
||||
void Shutdown(void* opaque_context) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr) {
|
||||
return;
|
||||
}
|
||||
std::unordered_map<const void*, std::shared_ptr<CaptureSession>>
|
||||
capture_sessions;
|
||||
std::unordered_map<const void*, std::shared_ptr<PlayoutSession>>
|
||||
playout_sessions;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(context->mutex);
|
||||
context->recording_enabled = false;
|
||||
context->shutting_down = true;
|
||||
context->initialized_managers.clear();
|
||||
capture_sessions.swap(context->capture_sessions);
|
||||
playout_sessions.swap(context->playout_sessions);
|
||||
}
|
||||
for (auto& [identity, session] : playout_sessions) {
|
||||
(void)identity;
|
||||
ClosePlayoutSession(session);
|
||||
}
|
||||
for (auto& [identity, session] : capture_sessions) {
|
||||
(void)identity;
|
||||
CloseCaptureSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Status InstallWebRtcJniAudioBridge(jnivm::VM* vm) {
|
||||
if (vm == nullptr) {
|
||||
return Status::Error(StatusCode::kInvalidArgument,
|
||||
"WebRTC JNI audio bridge requires a VM");
|
||||
}
|
||||
Status status = InitializeSdlAudioSubsystem();
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const char* configured = std::getenv("MOCKTAIL_AUDIO_INPUT_DEVICE");
|
||||
const std::string requested =
|
||||
configured != nullptr && configured[0] != '\0' ? configured : "default";
|
||||
auto context = std::shared_ptr<WebRtcJniAudioBridgeContext>(
|
||||
new (std::nothrow) WebRtcJniAudioBridgeContext());
|
||||
if (context == nullptr) {
|
||||
return Status::Error(StatusCode::kUnavailable,
|
||||
"unable to allocate WebRTC JNI audio bridge");
|
||||
}
|
||||
|
||||
if (requested == "disabled") {
|
||||
context->recording_enabled = false;
|
||||
std::cout << " [mocktail][audio] microphone capture disabled\n"
|
||||
<< std::flush;
|
||||
} else {
|
||||
std::vector<SdlRecordingDevice> devices;
|
||||
std::string resolved_name;
|
||||
status = ConfigureSdlRecordingDevice(requested, &devices, &resolved_name);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
std::cout << " [mocktail][audio] SDL recording devices:\n"
|
||||
<< " - default (follows the host system default)\n";
|
||||
for (const SdlRecordingDevice& device : devices) {
|
||||
std::cout << " - id:" << device.id << " " << device.name << '\n';
|
||||
}
|
||||
std::cout << " [mocktail][audio] selected input=" << resolved_name
|
||||
<< '\n'
|
||||
<< std::flush;
|
||||
}
|
||||
|
||||
jnivm::WebRtcAudioRecordCallbacks record_callbacks;
|
||||
record_callbacks.init = &Init;
|
||||
record_callbacks.start = &Start;
|
||||
record_callbacks.stop = &Stop;
|
||||
record_callbacks.close = &Close;
|
||||
record_callbacks.shutdown = &Shutdown;
|
||||
vm->SetWebRtcAudioRecordCallbacks(context, record_callbacks);
|
||||
|
||||
jnivm::WebRtcAudioManagerCallbacks manager_callbacks;
|
||||
manager_callbacks.get_parameters = &GetManagerParameters;
|
||||
manager_callbacks.init = &InitManager;
|
||||
manager_callbacks.dispose = &DisposeManager;
|
||||
manager_callbacks.set_microphone_mute = &SetMicrophoneMute;
|
||||
vm->SetWebRtcAudioManagerCallbacks(context, manager_callbacks);
|
||||
|
||||
jnivm::WebRtcAudioTrackCallbacks track_callbacks;
|
||||
track_callbacks.init = &InitPlayout;
|
||||
track_callbacks.buffer_size_frames = &PlayoutBufferSizeFrames;
|
||||
track_callbacks.start = &StartPlayout;
|
||||
track_callbacks.stop = &StopPlayout;
|
||||
track_callbacks.close = &ClosePlayout;
|
||||
vm->SetWebRtcAudioTrackCallbacks(std::move(context), track_callbacks);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status ShutdownWebRtcJniAudioBridge(jnivm::VM* vm) {
|
||||
if (vm == nullptr) {
|
||||
return Status::Error(StatusCode::kInvalidArgument,
|
||||
"WebRTC JNI audio bridge requires a VM");
|
||||
}
|
||||
// Drop dispatch first, then let the recording binding's shared context stop
|
||||
// both playout workers and capture streams.
|
||||
vm->ClearWebRtcAudioManagerCallbacks();
|
||||
vm->ClearWebRtcAudioTrackCallbacks();
|
||||
vm->ClearWebRtcAudioRecordCallbacks();
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
} // namespace mocktail::audio
|
||||
+616
-367
File diff suppressed because it is too large.
Load diff
@@ -1,61 +1,85 @@
|
||||
#include "compat/bionic_rwlock_runtime.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <linux/futex.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <climits>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
struct HostRwlockEntry {
|
||||
uintptr_t guest_address;
|
||||
pthread_rwlock_t host_rwlock;
|
||||
HostRwlockEntry* next;
|
||||
};
|
||||
// Bionic rwlock state word:
|
||||
// bit 31 writer owns
|
||||
// bits 30-2 reader count
|
||||
// bit 1 pending writers
|
||||
// bit 0 pending readers
|
||||
constexpr int kRwlockWriterFlag = 1 << 31;
|
||||
constexpr int kRwlockPendingWritersFlag = 1 << 1;
|
||||
constexpr int kRwlockPendingReadersFlag = 1 << 0;
|
||||
constexpr int kRwlockReaderStep = 1 << 2;
|
||||
constexpr int kRwlockPendingMask =
|
||||
kRwlockPendingWritersFlag | kRwlockPendingReadersFlag;
|
||||
|
||||
pthread_mutex_t g_table_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
HostRwlockEntry* g_table = nullptr;
|
||||
int* RwlockState(pthread_rwlock_t* rwlock) {
|
||||
return reinterpret_cast<int*>(rwlock);
|
||||
}
|
||||
|
||||
pthread_rwlock_t* FindOrCreateHostRwlock(pthread_rwlock_t* guest_rwlock,
|
||||
bool create) noexcept {
|
||||
if (guest_rwlock == nullptr ||
|
||||
reinterpret_cast<uintptr_t>(guest_rwlock) < 4096) {
|
||||
return nullptr;
|
||||
int32_t* RwlockWriterTid(pthread_rwlock_t* rwlock) {
|
||||
return reinterpret_cast<int32_t*>(reinterpret_cast<char*>(rwlock) + 4);
|
||||
}
|
||||
|
||||
pid_t RwlockTid() {
|
||||
thread_local pid_t tid = 0;
|
||||
if (__builtin_expect(tid == 0, 0)) {
|
||||
tid = ::gettid();
|
||||
}
|
||||
return tid;
|
||||
}
|
||||
|
||||
const uintptr_t guest_address = reinterpret_cast<uintptr_t>(guest_rwlock);
|
||||
::pthread_mutex_lock(&g_table_mutex);
|
||||
for (HostRwlockEntry* entry = g_table; entry != nullptr;
|
||||
entry = entry->next) {
|
||||
if (entry->guest_address == guest_address) {
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return &entry->host_rwlock;
|
||||
int RwlockFutex(int* state, int op, int val) {
|
||||
return static_cast<int>(::syscall(SYS_futex, state, op | FUTEX_PRIVATE_FLAG,
|
||||
val, nullptr, nullptr, 0));
|
||||
}
|
||||
|
||||
bool OwnedByWriter(int state) {
|
||||
return state < 0;
|
||||
}
|
||||
|
||||
bool OwnedByReaders(int state) {
|
||||
return state >= kRwlockReaderStep;
|
||||
}
|
||||
|
||||
int TryRdlock(int* state) {
|
||||
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
|
||||
while (!OwnedByWriter(old_state)) {
|
||||
const int new_state = old_state + kRwlockReaderStep;
|
||||
if (!OwnedByReaders(new_state)) {
|
||||
return EAGAIN;
|
||||
}
|
||||
if (__atomic_compare_exchange_n(state, &old_state, new_state, true,
|
||||
__ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return EBUSY;
|
||||
}
|
||||
|
||||
if (!create) {
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return nullptr;
|
||||
int TryWrlock(pthread_rwlock_t* rwlock) {
|
||||
int* state = RwlockState(rwlock);
|
||||
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
|
||||
while (!OwnedByWriter(old_state) && !OwnedByReaders(old_state)) {
|
||||
const int new_state = old_state | kRwlockWriterFlag;
|
||||
if (__atomic_compare_exchange_n(state, &old_state, new_state, true,
|
||||
__ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) {
|
||||
__atomic_store_n(RwlockWriterTid(rwlock), static_cast<int32_t>(RwlockTid()),
|
||||
__ATOMIC_RELAXED);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
auto* entry =
|
||||
static_cast<HostRwlockEntry*>(std::calloc(1, sizeof(HostRwlockEntry)));
|
||||
if (entry == nullptr) {
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return nullptr;
|
||||
}
|
||||
const int init_result = ::pthread_rwlock_init(&entry->host_rwlock, nullptr);
|
||||
if (init_result != 0) {
|
||||
std::free(entry);
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
entry->guest_address = guest_address;
|
||||
entry->next = g_table;
|
||||
g_table = entry;
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return &entry->host_rwlock;
|
||||
return EBUSY;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -64,45 +88,107 @@ extern "C" {
|
||||
|
||||
int mocktail_bionic_pthread_rwlock_init(
|
||||
pthread_rwlock_t* rwlock, const pthread_rwlockattr_t* /*attr*/) {
|
||||
return FindOrCreateHostRwlock(rwlock, true) == nullptr ? ENOMEM : 0;
|
||||
if (rwlock == nullptr ||
|
||||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
|
||||
return EINVAL;
|
||||
}
|
||||
std::memset(rwlock, 0, sizeof(*rwlock));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_bionic_pthread_rwlock_destroy(pthread_rwlock_t* rwlock) {
|
||||
if (rwlock == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
|
||||
const uintptr_t guest_address = reinterpret_cast<uintptr_t>(rwlock);
|
||||
::pthread_mutex_lock(&g_table_mutex);
|
||||
HostRwlockEntry** link = &g_table;
|
||||
while (*link != nullptr) {
|
||||
HostRwlockEntry* entry = *link;
|
||||
if (entry->guest_address == guest_address) {
|
||||
*link = entry->next;
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
const int result = ::pthread_rwlock_destroy(&entry->host_rwlock);
|
||||
std::free(entry);
|
||||
return result;
|
||||
}
|
||||
link = &entry->next;
|
||||
const int state = __atomic_load_n(RwlockState(rwlock), __ATOMIC_RELAXED);
|
||||
if ((state & ~kRwlockPendingMask) != 0) {
|
||||
return EBUSY;
|
||||
}
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_bionic_pthread_rwlock_rdlock(pthread_rwlock_t* rwlock) {
|
||||
pthread_rwlock_t* host_rwlock = FindOrCreateHostRwlock(rwlock, true);
|
||||
return host_rwlock == nullptr ? EINVAL : ::pthread_rwlock_rdlock(host_rwlock);
|
||||
if (rwlock == nullptr ||
|
||||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
|
||||
return EINVAL;
|
||||
}
|
||||
int* state = RwlockState(rwlock);
|
||||
if (TryRdlock(state) == 0) {
|
||||
return 0;
|
||||
}
|
||||
for (;;) {
|
||||
const int result = TryRdlock(state);
|
||||
if (result == 0 || result == EAGAIN) {
|
||||
return result;
|
||||
}
|
||||
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
|
||||
if (!OwnedByWriter(old_state)) {
|
||||
continue;
|
||||
}
|
||||
const int pending = old_state | kRwlockPendingReadersFlag;
|
||||
if (!__atomic_compare_exchange_n(state, &old_state, pending, true,
|
||||
__ATOMIC_RELAXED, __ATOMIC_RELAXED)) {
|
||||
continue;
|
||||
}
|
||||
(void)RwlockFutex(state, FUTEX_WAIT, pending);
|
||||
}
|
||||
}
|
||||
|
||||
int mocktail_bionic_pthread_rwlock_wrlock(pthread_rwlock_t* rwlock) {
|
||||
pthread_rwlock_t* host_rwlock = FindOrCreateHostRwlock(rwlock, true);
|
||||
return host_rwlock == nullptr ? EINVAL : ::pthread_rwlock_wrlock(host_rwlock);
|
||||
if (rwlock == nullptr ||
|
||||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
|
||||
return EINVAL;
|
||||
}
|
||||
if (__atomic_load_n(RwlockWriterTid(rwlock), __ATOMIC_RELAXED) ==
|
||||
static_cast<int32_t>(RwlockTid())) {
|
||||
return EDEADLK;
|
||||
}
|
||||
if (TryWrlock(rwlock) == 0) {
|
||||
return 0;
|
||||
}
|
||||
int* state = RwlockState(rwlock);
|
||||
for (;;) {
|
||||
if (TryWrlock(rwlock) == 0) {
|
||||
return 0;
|
||||
}
|
||||
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
|
||||
if (!OwnedByWriter(old_state) && !OwnedByReaders(old_state)) {
|
||||
continue;
|
||||
}
|
||||
const int pending = old_state | kRwlockPendingWritersFlag;
|
||||
if (!__atomic_compare_exchange_n(state, &old_state, pending, true,
|
||||
__ATOMIC_RELAXED, __ATOMIC_RELAXED)) {
|
||||
continue;
|
||||
}
|
||||
(void)RwlockFutex(state, FUTEX_WAIT, pending);
|
||||
}
|
||||
}
|
||||
|
||||
int mocktail_bionic_pthread_rwlock_unlock(pthread_rwlock_t* rwlock) {
|
||||
pthread_rwlock_t* host_rwlock = FindOrCreateHostRwlock(rwlock, false);
|
||||
return host_rwlock == nullptr ? EINVAL : ::pthread_rwlock_unlock(host_rwlock);
|
||||
if (rwlock == nullptr ||
|
||||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
|
||||
return EINVAL;
|
||||
}
|
||||
int* state = RwlockState(rwlock);
|
||||
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
|
||||
if (OwnedByWriter(old_state)) {
|
||||
if (__atomic_load_n(RwlockWriterTid(rwlock), __ATOMIC_RELAXED) !=
|
||||
static_cast<int32_t>(RwlockTid())) {
|
||||
return EPERM;
|
||||
}
|
||||
__atomic_store_n(RwlockWriterTid(rwlock), 0, __ATOMIC_RELAXED);
|
||||
old_state = __atomic_fetch_and(state, ~kRwlockWriterFlag, __ATOMIC_RELEASE);
|
||||
} else if (OwnedByReaders(old_state)) {
|
||||
old_state =
|
||||
__atomic_fetch_sub(state, kRwlockReaderStep, __ATOMIC_RELEASE);
|
||||
if ((old_state >> 2) != 1) {
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
return EPERM;
|
||||
}
|
||||
(void)RwlockFutex(state, FUTEX_WAKE, INT_MAX);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -1,62 +1,68 @@
|
||||
#include "compat/bionic_semaphore_runtime.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <linux/futex.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <climits>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
|
||||
struct HostSemaphoreEntry {
|
||||
uintptr_t guest_address;
|
||||
sem_t host_semaphore;
|
||||
HostSemaphoreEntry* next;
|
||||
};
|
||||
// Bionic encodes the signed count in bits 31:1 and a process-shared flag in
|
||||
// bit 0. Waiters park while the count is -1 (all value bits set, shared=0).
|
||||
constexpr unsigned kSemValueMask = 0xfffffffeu;
|
||||
constexpr unsigned kSemValueShift = 1u;
|
||||
constexpr unsigned kSemOne = 1u << kSemValueShift;
|
||||
constexpr unsigned kSemMinusOne = kSemValueMask;
|
||||
|
||||
pthread_mutex_t g_table_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
HostSemaphoreEntry* g_table = nullptr;
|
||||
unsigned* SemCount(sem_t* semaphore) {
|
||||
return reinterpret_cast<unsigned*>(semaphore);
|
||||
}
|
||||
|
||||
sem_t* FindOrCreateHostSemaphore(sem_t* guest_semaphore, bool create,
|
||||
unsigned int initial_value) noexcept {
|
||||
if (guest_semaphore == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
int SemValue(unsigned encoded) {
|
||||
return static_cast<int>(encoded) >> static_cast<int>(kSemValueShift);
|
||||
}
|
||||
|
||||
const uintptr_t guest_address =
|
||||
reinterpret_cast<uintptr_t>(guest_semaphore);
|
||||
::pthread_mutex_lock(&g_table_mutex);
|
||||
for (HostSemaphoreEntry* entry = g_table; entry != nullptr;
|
||||
entry = entry->next) {
|
||||
if (entry->guest_address == guest_address) {
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return &entry->host_semaphore;
|
||||
unsigned SemEncode(unsigned value) {
|
||||
return (value << kSemValueShift) & kSemValueMask;
|
||||
}
|
||||
|
||||
int SemFutex(unsigned* count, int op, unsigned val) {
|
||||
return static_cast<int>(
|
||||
::syscall(SYS_futex, count, op | FUTEX_PRIVATE_FLAG, val, nullptr,
|
||||
nullptr, 0));
|
||||
}
|
||||
|
||||
int SemDec(unsigned* count) {
|
||||
unsigned old_value = __atomic_load_n(count, __ATOMIC_RELAXED);
|
||||
do {
|
||||
if (SemValue(old_value) < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (!__atomic_compare_exchange_n(
|
||||
count, &old_value, (old_value - kSemOne) & kSemValueMask, true,
|
||||
__ATOMIC_SEQ_CST, __ATOMIC_RELAXED));
|
||||
return SemValue(old_value);
|
||||
}
|
||||
|
||||
if (!create) {
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto* entry = static_cast<HostSemaphoreEntry*>(
|
||||
std::calloc(1, sizeof(HostSemaphoreEntry)));
|
||||
if (entry == nullptr) {
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
errno = ENOMEM;
|
||||
return nullptr;
|
||||
}
|
||||
if (::sem_init(&entry->host_semaphore, 0, initial_value) != 0) {
|
||||
std::free(entry);
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
entry->guest_address = guest_address;
|
||||
entry->next = g_table;
|
||||
g_table = entry;
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return &entry->host_semaphore;
|
||||
int SemInc(unsigned* count) {
|
||||
unsigned old_value = __atomic_load_n(count, __ATOMIC_RELAXED);
|
||||
unsigned new_value = 0;
|
||||
do {
|
||||
const int value = SemValue(old_value);
|
||||
if (value == SEM_VALUE_MAX) {
|
||||
break;
|
||||
}
|
||||
if (value < 0) {
|
||||
new_value = kSemOne;
|
||||
} else {
|
||||
new_value = (old_value + kSemOne) & kSemValueMask;
|
||||
}
|
||||
} while (!__atomic_compare_exchange_n(count, &old_value, new_value, true,
|
||||
__ATOMIC_SEQ_CST, __ATOMIC_RELAXED));
|
||||
return SemValue(old_value);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -69,7 +75,12 @@ int mocktail_bionic_sem_init(sem_t* semaphore, int process_shared,
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
return FindOrCreateHostSemaphore(semaphore, true, value) != nullptr ? 0 : -1;
|
||||
if (value > static_cast<unsigned>(SEM_VALUE_MAX)) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
__atomic_store_n(SemCount(semaphore), SemEncode(value), __ATOMIC_RELAXED);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_bionic_sem_destroy(sem_t* semaphore) {
|
||||
@@ -77,46 +88,40 @@ int mocktail_bionic_sem_destroy(sem_t* semaphore) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
const uintptr_t guest_address = reinterpret_cast<uintptr_t>(semaphore);
|
||||
::pthread_mutex_lock(&g_table_mutex);
|
||||
HostSemaphoreEntry** link = &g_table;
|
||||
while (*link != nullptr) {
|
||||
HostSemaphoreEntry* entry = *link;
|
||||
if (entry->guest_address == guest_address) {
|
||||
*link = entry->next;
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
const int result = ::sem_destroy(&entry->host_semaphore);
|
||||
std::free(entry);
|
||||
return result;
|
||||
}
|
||||
link = &entry->next;
|
||||
}
|
||||
::pthread_mutex_unlock(&g_table_mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_bionic_sem_wait(sem_t* semaphore) {
|
||||
sem_t* host_semaphore = FindOrCreateHostSemaphore(semaphore, false, 0);
|
||||
if (host_semaphore == nullptr) {
|
||||
if (semaphore == nullptr) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int result = 0;
|
||||
do {
|
||||
result = ::sem_wait(host_semaphore);
|
||||
} while (result != 0 && errno == EINTR);
|
||||
return result;
|
||||
unsigned* count = SemCount(semaphore);
|
||||
for (;;) {
|
||||
if (SemDec(count) > 0) {
|
||||
return 0;
|
||||
}
|
||||
const int rc = SemFutex(count, FUTEX_WAIT, kSemMinusOne);
|
||||
if (rc != 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int mocktail_bionic_sem_post(sem_t* semaphore) {
|
||||
sem_t* host_semaphore = FindOrCreateHostSemaphore(semaphore, false, 0);
|
||||
if (host_semaphore == nullptr) {
|
||||
if (semaphore == nullptr) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
return ::sem_post(host_semaphore);
|
||||
unsigned* count = SemCount(semaphore);
|
||||
const int old_value = SemInc(count);
|
||||
if (old_value < 0) {
|
||||
(void)SemFutex(count, FUTEX_WAKE, static_cast<unsigned>(INT_MAX));
|
||||
} else if (old_value == SEM_VALUE_MAX) {
|
||||
errno = EOVERFLOW;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -17,9 +17,9 @@ FILE* g_bionic_stdin = reinterpret_cast<FILE*>(&__sF[0]);
|
||||
FILE* g_bionic_stdout = reinterpret_cast<FILE*>(&__sF[1]);
|
||||
FILE* g_bionic_stderr = reinterpret_cast<FILE*>(&__sF[2]);
|
||||
|
||||
FILE* RequireHostStream(FILE* stream) {
|
||||
inline FILE* RequireHostStream(FILE* stream) {
|
||||
FILE* host_stream = mocktail::compat::TranslateBionicFile(stream);
|
||||
if (host_stream != nullptr) {
|
||||
if (__builtin_expect(host_stream != nullptr, 1)) {
|
||||
return host_stream;
|
||||
}
|
||||
errno = EINVAL;
|
||||
@@ -30,20 +30,6 @@ FILE* RequireHostStream(FILE* stream) {
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
FILE* TranslateBionicFile(FILE* stream) noexcept {
|
||||
const uintptr_t address = reinterpret_cast<uintptr_t>(stream);
|
||||
if (address == reinterpret_cast<uintptr_t>(&__sF[0])) {
|
||||
return stdin;
|
||||
}
|
||||
if (address == reinterpret_cast<uintptr_t>(&__sF[1])) {
|
||||
return stdout;
|
||||
}
|
||||
if (address == reinterpret_cast<uintptr_t>(&__sF[2])) {
|
||||
return stderr;
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
void* BionicFileArraySymbolAddress() noexcept { return &__sF[0]; }
|
||||
|
||||
void* BionicStdinSymbolAddress() noexcept { return &g_bionic_stdin; }
|
||||
|
||||
@@ -6,6 +6,54 @@ namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
constexpr HostAbiProfile kProfiles[] = {
|
||||
// Roblox 2.736.1408. Derived from the verified 2908 profile and validated
|
||||
// by two isolated no-recovery Vulkan canaries and real game sessions.
|
||||
{"ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
{{{0x1d2c1e2, HostBridgeKind::kAllocate, "small-allocate"},
|
||||
{0x1d2f43a, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x21e68bf, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1d2bf82, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x1d47a63, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1d2f9d9, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0x7652e70, 0x775fd28, 0x7bb4340, 0x400, 0x1d2c5f8, 0x1db7b57,
|
||||
0x7af0430, 0x7af04c0, 0x400000},
|
||||
{0x1d2bf82, 0x1d2f9d9},
|
||||
0x7057818,
|
||||
3570,
|
||||
{{{2, 3}, {5, 3570}}},
|
||||
2,
|
||||
{{{2, 3570}}},
|
||||
1,
|
||||
2,
|
||||
{0x21e8e09, 0x7af4598},
|
||||
HostAllocatorStrategy::kNativeMimalloc},
|
||||
// Roblox 2.734.917. This exact Build-ID profile was machine-derived from
|
||||
// the verified reference and passed two isolated no-recovery Tier C Vulkan
|
||||
// canaries with native constructors, JNI startup, presentation, and
|
||||
// controlled lifecycle teardown.
|
||||
{"63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
|
||||
{{{0x1cf69e2, HostBridgeKind::kAllocate, "small-allocate"},
|
||||
{0x1cf9c3a, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x21af33f, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1cf677e, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x1d12197, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1cfa1d9, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0x7571570, 0x767e428, 0x7ad47e8, 0x400, 0x1cf6df8, 0x1d82be7,
|
||||
0x7a10730, 0x7a107c0, 0x400000},
|
||||
{0x1cf677e, 0x1cfa1d9},
|
||||
0x6f78a18,
|
||||
3556,
|
||||
{{{2, 3}, {5, 3556}}},
|
||||
2,
|
||||
{{{2, 3556}}},
|
||||
1,
|
||||
2,
|
||||
{0x21b1889, 0x7a14898},
|
||||
HostAllocatorStrategy::kNativeMimalloc},
|
||||
// Roblox 2.727.1199. Every function and state slot below was matched
|
||||
// independently against the previous supported payload by normalized
|
||||
// instruction signatures and RIP-relative references. The payload keeps
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#define JSON_NOEXCEPTION 1
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <cstddef>
|
||||
@@ -28,14 +29,47 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "mocktail/sha256.h"
|
||||
|
||||
namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
using Json = nlohmann::json;
|
||||
|
||||
std::atomic<bool> g_hashing_fault_for_testing{false};
|
||||
|
||||
std::string FormatHex(const unsigned char* data, std::size_t size,
|
||||
std::size_t max_chars = 0) {
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
const std::size_t limit =
|
||||
(max_chars > 0 && max_chars < size * 2U) ? max_chars : size * 2U;
|
||||
std::string hex;
|
||||
hex.reserve(limit);
|
||||
for (std::size_t i = 0; i < size && hex.size() < limit; ++i) {
|
||||
hex.push_back(kHex[(data[i] >> 4) & 0x0f]);
|
||||
if (hex.size() < limit) {
|
||||
hex.push_back(kHex[data[i] & 0x0f]);
|
||||
}
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
std::string ComputeSha256Hex(std::string_view bytes,
|
||||
std::size_t max_chars = 0) {
|
||||
if (g_hashing_fault_for_testing.load(std::memory_order_acquire)) {
|
||||
return {};
|
||||
}
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int len = 0;
|
||||
if (EVP_Digest(bytes.data(), bytes.size(), digest, &len, EVP_sha256(),
|
||||
nullptr) != 1) {
|
||||
return {};
|
||||
}
|
||||
return FormatHex(digest, len, max_chars);
|
||||
}
|
||||
|
||||
constexpr std::size_t kMaximumProfileBytes = 1024 * 1024;
|
||||
constexpr std::size_t kMaximumReceiptBytes = 64 * 1024;
|
||||
constexpr std::size_t kMaximumInitArrayEntries = 1024 * 1024;
|
||||
@@ -862,7 +896,11 @@ bool ValidateCanaryAttestation(const std::string& attestation_path,
|
||||
return false;
|
||||
}
|
||||
|
||||
evidence->sha256 = foundation::ComputeSha256Hex(attestation_bytes);
|
||||
evidence->sha256 = ComputeSha256Hex(attestation_bytes);
|
||||
if (evidence->sha256.empty()) {
|
||||
*error = "cannot compute canary attestation SHA-256";
|
||||
return false;
|
||||
}
|
||||
evidence->run_id = std::move(run_id);
|
||||
return true;
|
||||
}
|
||||
@@ -1013,7 +1051,7 @@ bool ValidateReceipt(const std::string& receipt_path,
|
||||
.append(canary_evidence[1].sha256)
|
||||
.append("\n");
|
||||
const std::string expected_generation =
|
||||
foundation::ComputeSha256Hex(generation_evidence).substr(0, 40);
|
||||
ComputeSha256Hex(generation_evidence, 40);
|
||||
if (generation != expected_generation) {
|
||||
*error = "approval receipt evidence generation does not match";
|
||||
return false;
|
||||
@@ -1066,19 +1104,31 @@ FileSha256Result ComputeFileSha256(const std::string& path) {
|
||||
if (!input) {
|
||||
return {{}, "cannot open file for SHA-256: " + path};
|
||||
}
|
||||
foundation::Sha256 sha256;
|
||||
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> ctx(
|
||||
EVP_MD_CTX_new(), &EVP_MD_CTX_free);
|
||||
if (!ctx || EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr) != 1) {
|
||||
return {{}, "cannot initialize SHA-256 digest context for: " + path};
|
||||
}
|
||||
std::array<unsigned char, 128 * 1024> buffer{};
|
||||
while (input) {
|
||||
input.read(reinterpret_cast<char*>(buffer.data()), buffer.size());
|
||||
const std::streamsize read = input.gcount();
|
||||
if (read > 0) {
|
||||
sha256.Update(buffer.data(), static_cast<std::size_t>(read));
|
||||
if (EVP_DigestUpdate(ctx.get(), buffer.data(),
|
||||
static_cast<std::size_t>(read)) != 1) {
|
||||
return {{}, "cannot update SHA-256 digest for: " + path};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!input.eof()) {
|
||||
return {{}, "cannot read file for SHA-256: " + path};
|
||||
}
|
||||
return {sha256.FinalHex(), {}};
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int len = 0;
|
||||
if (EVP_DigestFinal_ex(ctx.get(), digest, &len) != 1) {
|
||||
return {{}, "cannot finalize SHA-256 digest for: " + path};
|
||||
}
|
||||
return {FormatHex(digest, len), {}};
|
||||
}
|
||||
|
||||
const HostAbiProfile* FindLoadedExternalHostAbiProfile(
|
||||
@@ -1161,11 +1211,10 @@ ExternalHostAbiProfileResult LoadExternalHostAbiProfile(
|
||||
return Failure(
|
||||
"external host ABI profile SHA-256 does not match payload bytes");
|
||||
}
|
||||
foundation::Sha256 profile_hasher;
|
||||
profile_hasher.Update(
|
||||
reinterpret_cast<const unsigned char*>(profile_bytes.data()),
|
||||
profile_bytes.size());
|
||||
const std::string profile_sha256 = profile_hasher.FinalHex();
|
||||
const std::string profile_sha256 = ComputeSha256Hex(profile_bytes);
|
||||
if (profile_sha256.empty()) {
|
||||
return Failure("cannot compute external host ABI profile SHA-256");
|
||||
}
|
||||
if (!ValidateProfileAgainstElf(*canonical_payload, profile->profile,
|
||||
profile->derivation_code_rvas, &path_error)) {
|
||||
return Failure(path_error);
|
||||
@@ -1243,4 +1292,8 @@ ExternalHostAbiProfileResult InitializeHostAbiProfileFromEnvironment(
|
||||
return LoadExternalHostAbiProfile(request);
|
||||
}
|
||||
|
||||
void SetHostAbiProfileHashingFaultForTesting(bool enabled) noexcept {
|
||||
g_hashing_fault_for_testing.store(enabled, std::memory_order_release);
|
||||
}
|
||||
|
||||
} // namespace mocktail::compat
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user