Compare commits

..
21 Commits
Author SHA1 Message Date
komaruworld 9bdd737d10 Release 1.0.2 2026-08-14 20:58:03 +03:00
komaruworld df253e786f Upd flatpak metainfo 2026-08-14 20:40:38 +03:00
komaruworld 53cd79b7da fix ci 2026-08-14 20:18:26 +03:00
komaruworld febebef554 Update website and add apt,rpm repository 2026-08-14 20:12:36 +03:00
komaruworld 23fe2eeca5 improved Discord rpc, autoupdate
fixes #4
2026-08-14 00:34:46 +03:00
komaruworld 6f5de3a31d improved ci 2026-08-13 22:36:42 +03:00
komaruworld ea5929faf6 improved the logs 2026-08-13 18:37:30 +03:00
komaruworld 220c083e83 Fix SDL3_ttf CMake linkage 2026-08-13 13:29:38 +03:00
komaruworld d778a5bc8a Update AUR packages for 1.0.1 2026-08-13 12:42:13 +03:00
komaruworld 0a039628db Sync Discord RPC configuration help 2026-08-13 12:24:02 +03:00
komaruworld b40fd5d2d6 Release 1.0.1 2026-08-13 12:20:31 +03:00
komaruworld 5fb44c66e6 upd flatpak description 2026-08-12 21:40:58 +03:00
komaruworld 5d636749fa rewrite some text 2026-08-12 20:02:54 +03:00
komaruworld 88b7382577 Fix some issues with discord rpc 2026-08-12 18:18:51 +03:00
komaruworld 3ce39c1f39 Fix autoupdate
Fixed a bug where the payload was being checked even though it had already been checked
2026-08-12 18:16:26 +03:00
komaruworld 6aa22c257d add discord rpc and fix issues with gamemode 2026-08-12 17:42:34 +03:00
komaruworld e5cf5e3de4 add mocktail.bigrat.space domian to github pages 2026-08-12 16:00:27 +03:00
komaruworld 36733cfb75 make better pkgbuild
thx strz
2026-08-12 13:31:15 +03:00
komaruworld ca760f111b fix flatpak
fix issues with "rbx_bin/libroblox.so: No such file"
2026-08-12 13:30:24 +03:00
komaruworld bfb27b4104 upd ci 2026-08-12 10:59:46 +03:00
komaruworld 5663fe7d25 upd shit 2026-08-12 10:50:43 +03:00
143 changed files with 5341 additions and 2242 deletions

No files matched your search

+20 -115
View File
@@ -3,7 +3,27 @@ name: CI
on:
push:
branches: [main]
paths:
- '.github/workflows/**'
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- 'config/**'
- 'packaging/**'
- 'scripts/**'
- 'stubs/**'
- 'tests/**'
pull_request:
paths:
- '.github/workflows/**'
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- 'config/**'
- 'packaging/**'
- 'scripts/**'
- 'stubs/**'
- 'tests/**'
workflow_dispatch:
permissions:
@@ -38,118 +58,3 @@ jobs:
bash tests/managed_payload_build_contract_test.sh .
bash tests/flatpak_packaging_test.sh .
bash tests/native_packaging_test.sh .
flatpak:
name: Flatpak build
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Install Flatpak builder
shell: bash
run: |
sudo apt-get update
sudo apt-get install --yes flatpak flatpak-builder gnupg
flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
- name: Build Flatpak repository and bundle
shell: bash
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
run: |
set -Eeuo pipefail
git update-ref refs/heads/main "$GITHUB_SHA"
builder_signing=()
bundle_keys=()
publish=false
if [[ "$GITHUB_REF" == refs/heads/main && \
"$GITHUB_EVENT_NAME" != pull_request ]]; then
publish=true
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && \
-n "$FLATPAK_GPG_KEY_ID" ]]
install -d -m 0700 "$RUNNER_TEMP/mocktail-gnupg"
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" --import
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" \
--export "$FLATPAK_GPG_KEY_ID" >mocktail-flatpak.gpg
builder_signing=(
"--gpg-sign=$FLATPAK_GPG_KEY_ID"
"--gpg-homedir=$RUNNER_TEMP/mocktail-gnupg"
)
bundle_keys=(--gpg-keys=mocktail-flatpak.gpg)
fi
flatpak-builder \
--arch=x86_64 \
--jobs=4 \
--user \
--install-deps-from=flathub \
--disable-rofiles-fuse \
--force-clean \
--default-branch=stable \
--repo=repo \
"${builder_signing[@]}" \
build-flatpak-ci \
packaging/flatpak/space.bigrat.mocktail.json
if [[ "$publish" == true ]]; then
flatpak build-update-repo \
--generate-static-deltas \
--static-delta-jobs=2 \
--prune \
--gpg-sign="$FLATPAK_GPG_KEY_ID" \
--gpg-homedir="$RUNNER_TEMP/mocktail-gnupg" \
repo
fi
flatpak build-bundle \
--arch=x86_64 \
"${bundle_keys[@]}" \
repo \
Mocktail-x86_64.flatpak \
space.bigrat.mocktail \
stable
if [[ "$publish" == true ]]; then
./scripts/assemble_flatpak_pages.sh \
repo Mocktail-x86_64.flatpak mocktail-flatpak.gpg public
fi
- name: Upload Flatpak
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Mocktail-x86_64-flatpak
path: Mocktail-x86_64.flatpak
if-no-files-found: error
retention-days: 14
- name: Configure GitHub Pages
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload GitHub Pages repository
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: public
deploy-pages:
name: Publish Flatpak repository
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: flatpak
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+193
View File
@@ -0,0 +1,193 @@
name: Flatpak
on:
workflow_run:
workflows: [Native packages]
types: [completed]
pull_request:
paths:
- '.github/workflows/flatpak.yml'
- '.gitmodules'
- 'CMakeLists.txt'
- 'LICENSE'
- 'assets/screenshots/**'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/flatpak/**'
- 'packaging/discord-join.html'
- 'packaging/icons/**'
- 'packaging/space.bigrat.mocktail.desktop'
- 'packaging/space.bigrat.mocktail.metainfo.xml'
- 'packaging/space.bigrat.mocktail.svg'
- 'scripts/assemble_flatpak_pages.sh'
- 'scripts/assemble_native_repositories.sh'
- 'site/**'
- 'src/**'
- 'stubs/**'
- 'third_party/**'
workflow_dispatch:
permissions:
actions: read
contents: read
concurrency:
group: flatpak-${{ github.event.workflow_run.head_sha || github.ref }}
cancel-in-progress: true
jobs:
flatpak:
name: Flatpak build
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'pull_request' &&
github.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
MOCKTAIL_PUBLISH: ${{ (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request' && github.event.workflow_run.head_branch == 'main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
MOCKTAIL_SOURCE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
submodules: recursive
- name: Download package repositories
if: env.MOCKTAIL_PUBLISH == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -Eeuo pipefail
run_id="${{ github.event.workflow_run.id }}"
if [[ -z "$run_id" ]]; then
run_id="$(gh run list \
--workflow packages.yml \
--branch main \
--event push \
--status success \
--limit 1 \
--json databaseId \
--jq '.[0].databaseId')"
fi
[[ -n "$run_id" ]]
gh run download "$run_id" \
--name Mocktail-native-repositories \
--dir native-repositories
[[ -d native-repositories/apt && \
-d native-repositories/rpm && \
-d native-repositories/downloads && \
-s native-repositories/mocktail-packages.gpg ]]
- name: Install Flatpak builder
shell: bash
run: |
sudo apt-get update
sudo apt-get install --yes flatpak flatpak-builder gnupg
flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
- name: Build Flatpak repository and bundle
shell: bash
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
run: |
set -Eeuo pipefail
git update-ref refs/heads/main "$MOCKTAIL_SOURCE_SHA"
builder_signing=()
bundle_keys=()
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && \
-n "$FLATPAK_GPG_KEY_ID" ]]
install -d -m 0700 "$RUNNER_TEMP/mocktail-gnupg"
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" --import
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" \
--export "$FLATPAK_GPG_KEY_ID" >mocktail-flatpak.gpg
builder_signing=(
"--gpg-sign=$FLATPAK_GPG_KEY_ID"
"--gpg-homedir=$RUNNER_TEMP/mocktail-gnupg"
)
bundle_keys=(--gpg-keys=mocktail-flatpak.gpg)
fi
flatpak-builder \
--arch=x86_64 \
--jobs=4 \
--user \
--install-deps-from=flathub \
--disable-rofiles-fuse \
--force-clean \
--default-branch=stable \
--repo=repo \
"${builder_signing[@]}" \
build-flatpak-ci \
packaging/flatpak/space.bigrat.mocktail.json
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
flatpak build-update-repo \
--generate-static-deltas \
--static-delta-jobs=2 \
--prune \
--gpg-sign="$FLATPAK_GPG_KEY_ID" \
--gpg-homedir="$RUNNER_TEMP/mocktail-gnupg" \
repo
fi
flatpak build-bundle \
--arch=x86_64 \
"${bundle_keys[@]}" \
repo \
Mocktail-x86_64.flatpak \
space.bigrat.mocktail \
stable
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
./scripts/assemble_flatpak_pages.sh \
repo Mocktail-x86_64.flatpak mocktail-flatpak.gpg \
public native-repositories
fi
- name: Upload Flatpak
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Mocktail-x86_64-flatpak
path: Mocktail-x86_64.flatpak
if-no-files-found: error
retention-days: 14
- name: Configure GitHub Pages
if: env.MOCKTAIL_PUBLISH == 'true'
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload GitHub Pages repository
if: env.MOCKTAIL_PUBLISH == 'true'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: public
deploy-pages:
name: Publish Flatpak repository
if: >-
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'pull_request' &&
github.event.workflow_run.head_branch == 'main') ||
(github.event_name == 'workflow_dispatch' &&
github.ref == 'refs/heads/main')
needs: flatpak
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+84 -2
View File
@@ -3,7 +3,25 @@ name: Native packages
on:
push:
branches: [main]
paths-ignore:
- '.github/workflows/ci.yml'
- '.gitignore'
- 'README.md'
- 'assets/screenshots/**'
- 'docs/**'
- 'packaging/aur/**'
- 'scripts/build_flatpak.sh'
- 'tests/**'
pull_request:
paths-ignore:
- '.github/workflows/ci.yml'
- '.gitignore'
- 'README.md'
- 'assets/screenshots/**'
- 'docs/**'
- 'packaging/aur/**'
- 'scripts/build_flatpak.sh'
- 'tests/**'
workflow_dispatch:
permissions:
@@ -97,9 +115,11 @@ jobs:
- name: Build DEB
shell: bash
run: |
nightly_suffix=".nightly.$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S "$GITHUB_SHA").g${GITHUB_SHA:0:8}"
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION_SUFFIX="$nightly_suffix" \
ubuntu:26.04 bash -Eeuo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
@@ -114,6 +134,8 @@ jobs:
cmake -S . -B build-deb -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
-DBUILD_TESTING=OFF
cmake --build build-deb --parallel 4
cpack --config build-deb/CPackConfig.cmake \
@@ -143,9 +165,11 @@ jobs:
- name: Build RPM
shell: bash
run: |
nightly_suffix=".nightly.$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S "$GITHUB_SHA").g${GITHUB_SHA:0:8}"
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION_SUFFIX="$nightly_suffix" \
fedora:44 bash -Eeuo pipefail -c '
dnf install --assumeyes \
gcc-c++ cmake git ninja-build pkgconf-pkg-config rpm-build \
@@ -158,6 +182,8 @@ jobs:
cmake -S . -B build-rpm -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
-DBUILD_TESTING=OFF
cmake --build build-rpm --parallel 4
cpack --config build-rpm/CPackConfig.cmake \
@@ -219,7 +245,7 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: [appimage, deb, rpm, arch]
runs-on: ubuntu-24.04
timeout-minutes: 15
timeout-minutes: 30
permissions:
contents: write
steps:
@@ -235,13 +261,52 @@ jobs:
path: release
merge-multiple: true
- name: Prepare continuous packages
shell: bash
run: |
set -Eeuo pipefail
mv -- release/*.AppImage release/mocktail-nightly.AppImage
mv -- release/*.deb release/mocktail-nightly.deb
mv -- release/*.rpm release/mocktail-nightly.rpm
mv -- release/*.pkg.tar.zst release/mocktail-nightly.pkg.tar.zst
find release -maxdepth 1 -type f -print
- name: Build package repositories
shell: bash
env:
GH_TOKEN: ${{ github.token }}
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
run: |
set -Eeuo pipefail
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && -n "$FLATPAK_GPG_KEY_ID" ]]
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
apt-utils createrepo-c gnupg rpm
install -d stable/deb stable/rpm
stable_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
gh release download "$stable_tag" --pattern '*.deb' --dir stable/deb
gh release download "$stable_tag" --pattern '*.rpm' --dir stable/rpm
mapfile -t stable_debs < <(find stable/deb -maxdepth 1 -type f -name '*.deb' -print)
mapfile -t stable_rpms < <(find stable/rpm -maxdepth 1 -type f -name '*.rpm' -print)
[[ "${#stable_debs[@]}" -eq 1 && "${#stable_rpms[@]}" -eq 1 ]]
install -d -m 0700 "$RUNNER_TEMP/mocktail-native-gnupg"
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
gpg --batch --homedir "$RUNNER_TEMP/mocktail-native-gnupg" --import
gpg --batch --homedir "$RUNNER_TEMP/mocktail-native-gnupg" \
--export "$FLATPAK_GPG_KEY_ID" >mocktail-packages.gpg
./scripts/assemble_native_repositories.sh \
"${stable_debs[0]}" release/mocktail-nightly.deb \
"${stable_rpms[0]}" release/mocktail-nightly.rpm \
mocktail-packages.gpg "$FLATPAK_GPG_KEY_ID" \
"$RUNNER_TEMP/mocktail-native-gnupg" native-repositories
- name: Update continuous release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -Eeuo pipefail
find release -maxdepth 1 -type f -print
git config user.name komaruworld
git config user.email rtysus228@gmail.com
git tag --force continuous "$GITHUB_SHA"
@@ -253,3 +318,20 @@ jobs:
--notes "Automated development packages from main."
fi
gh release upload continuous release/* --clobber
while IFS= read -r asset; do
case "$asset" in
mocktail-nightly.AppImage|mocktail-nightly.deb|mocktail-nightly.rpm|mocktail-nightly.pkg.tar.zst)
;;
Mocktail-*.AppImage|mocktail_*.deb|mocktail-*.deb|mocktail-*.rpm|mocktail-*.pkg.tar.zst)
gh release delete-asset continuous "$asset" --yes
;;
esac
done < <(gh release view continuous --json assets --jq '.assets[].name')
- name: Upload package repositories
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Mocktail-native-repositories
path: native-repositories
if-no-files-found: error
retention-days: 14
+87 -2
View File
@@ -8,7 +8,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 0.1.0
VERSION 1.0.2
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -16,6 +16,40 @@ project(Mocktail
include(GNUInstallDirs)
find_package(Threads REQUIRED)
set(MOCKTAIL_BUILD_GIT_COMMIT "unknown")
set(MOCKTAIL_BUILD_GIT_DIRTY "unknown")
find_package(Git QUIET)
if(Git_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git")
execute_process(
COMMAND "${GIT_EXECUTABLE}" rev-parse --short=12 HEAD
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
RESULT_VARIABLE MOCKTAIL_GIT_REV_PARSE_RESULT
OUTPUT_VARIABLE MOCKTAIL_GIT_REV_PARSE_OUTPUT
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(MOCKTAIL_GIT_REV_PARSE_RESULT EQUAL 0 AND
MOCKTAIL_GIT_REV_PARSE_OUTPUT MATCHES "^[0-9a-fA-F]+$")
string(TOLOWER "${MOCKTAIL_GIT_REV_PARSE_OUTPUT}"
MOCKTAIL_BUILD_GIT_COMMIT)
execute_process(
COMMAND "${GIT_EXECUTABLE}" status --porcelain --untracked-files=normal
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
RESULT_VARIABLE MOCKTAIL_GIT_STATUS_RESULT
OUTPUT_VARIABLE MOCKTAIL_GIT_STATUS_OUTPUT
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(MOCKTAIL_GIT_STATUS_RESULT EQUAL 0)
if(MOCKTAIL_GIT_STATUS_OUTPUT STREQUAL "")
set(MOCKTAIL_BUILD_GIT_DIRTY "false")
else()
set(MOCKTAIL_BUILD_GIT_DIRTY "true")
endif()
endif()
endif()
endif()
set(MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST
"${CMAKE_SOURCE_DIR}/config/roblox_compatibility.json"
CACHE FILEPATH
@@ -26,6 +60,18 @@ set(MOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST
CACHE FILEPATH
"Fallback signing trust manifest used outside an installed runtime"
)
set(MOCKTAIL_DISCORD_APPLICATION_ID "1537088975720812655" CACHE STRING
"Discord application ID baked into Mocktail Rich Presence releases"
)
string(LENGTH "${MOCKTAIL_DISCORD_APPLICATION_ID}"
MOCKTAIL_DISCORD_APPLICATION_ID_LENGTH)
if(NOT MOCKTAIL_DISCORD_APPLICATION_ID STREQUAL "" AND
(NOT MOCKTAIL_DISCORD_APPLICATION_ID MATCHES "^[0-9]+$" OR
MOCKTAIL_DISCORD_APPLICATION_ID_LENGTH LESS 17 OR
MOCKTAIL_DISCORD_APPLICATION_ID_LENGTH GREATER 20))
message(FATAL_ERROR
"MOCKTAIL_DISCORD_APPLICATION_ID must be empty or a Discord snowflake")
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -221,6 +267,7 @@ target_include_directories(mocktail_legacy_jnivm PUBLIC
add_library(mocktail_runtime STATIC
src/runtime/command_line.cc
src/runtime/crash_report_policy.cc
src/runtime/discord_rpc.cc
src/runtime/device_profile.cc
src/runtime/environment.cc
src/runtime/external_launch_broker.cc
@@ -246,6 +293,7 @@ add_library(mocktail_runtime STATIC
src/runtime/runtime_config_bootstrap.cc
src/runtime/runtime_config_file.cc
src/runtime/runtime_paths.cc
src/runtime/session_log.cc
src/runtime/single_instance_lock.cc
src/runtime/startup_state_machine.cc
src/runtime/supported_launch_policy.cc
@@ -256,6 +304,20 @@ target_include_directories(mocktail_runtime PUBLIC include)
target_include_directories(mocktail_runtime PUBLIC
${MOCKTAIL_JNI_INCLUDE_DIRS}
)
target_compile_definitions(mocktail_runtime PRIVATE
"MOCKTAIL_DISCORD_APPLICATION_ID=\"${MOCKTAIL_DISCORD_APPLICATION_ID}\""
)
set_property(SOURCE src/runtime/session_log.cc APPEND PROPERTY
COMPILE_DEFINITIONS
"MOCKTAIL_PROJECT_VERSION=\"${PROJECT_VERSION}\""
"MOCKTAIL_BUILD_GIT_COMMIT=\"${MOCKTAIL_BUILD_GIT_COMMIT}\""
"MOCKTAIL_BUILD_GIT_DIRTY=\"${MOCKTAIL_BUILD_GIT_DIRTY}\""
"MOCKTAIL_BUILD_TYPE=\"$<CONFIG>\""
"MOCKTAIL_BUILD_COMPILER_ID=\"${CMAKE_CXX_COMPILER_ID}\""
"MOCKTAIL_BUILD_COMPILER_VERSION=\"${CMAKE_CXX_COMPILER_VERSION}\""
"MOCKTAIL_BUILD_TARGET_SYSTEM=\"${CMAKE_SYSTEM_NAME}\""
"MOCKTAIL_BUILD_TARGET_PROCESSOR=\"${CMAKE_SYSTEM_PROCESSOR}\""
)
mocktail_apply_compile_options(mocktail_runtime)
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 16)
@@ -655,7 +717,20 @@ install(CODE "
endif()
")
set(CPACK_PACKAGE_NAME "mocktail")
set(MOCKTAIL_PACKAGE_NAME "mocktail" CACHE STRING
"Native package name"
)
set(MOCKTAIL_PACKAGE_VERSION_SUFFIX "" CACHE STRING
"Native package version suffix"
)
if(NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail" AND
NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
message(FATAL_ERROR
"MOCKTAIL_PACKAGE_NAME must be mocktail or mocktail-nightly")
endif()
set(CPACK_PACKAGE_NAME "${MOCKTAIL_PACKAGE_NAME}")
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}")
set(CPACK_PACKAGE_VENDOR "Mocktail Project")
set(CPACK_PACKAGE_CONTACT "komaruworld <rtysus228@gmail.com>")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/komaruworld/mocktail")
@@ -677,6 +752,16 @@ set(CPACK_RPM_FILE_NAME RPM-DEFAULT)
set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
else()
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
endif()
include(CPack)
add_dependencies(mocktail all_stubs)
+1 -1
View File
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2026 komaruworld
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+2 -5
View File
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := all
.PHONY: all build release debug test apk install register-url-handler release-runtime portable portable-test standalone appimage flatpak run-flatpak run run-smoke run-unlimited run-game run-input run-resize run-network auto-run run-gles run-angle update-roblox update-auto update-auto-launch install-updater payload-status payload-rollback support-bundle clean submodules help
.PHONY: all build release debug test apk install register-url-handler release-runtime portable portable-test standalone appimage flatpak run-flatpak run run-smoke run-unlimited run-game run-input run-resize run-network auto-run run-gles run-angle update-roblox update-auto update-auto-launch payload-status payload-rollback support-bundle clean submodules help
BUILD_DIR := build
BINARY := $(BUILD_DIR)/mocktail
@@ -134,16 +134,13 @@ run-angle: ## Run with ANGLE/Vulkan compatibility backend
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
@./scripts/update_roblox_payload.sh
update-auto: build ## Download latest Roblox, derive its ABI, canary twice and activate safely
update-auto: build ## Download latest Roblox, derive its ABI, run two canaries, then activate
@./$(BUILD_DIR)/mocktail_updater update
update-auto-launch: build ## Update Roblox and launch the verified current payload
@./$(BUILD_DIR)/mocktail_updater update
@./$(BINARY)
install-updater: ## Install and enable the six-hour systemd user update timer
@./scripts/install_user_updater.sh
payload-status: build ## Show current and previous-good immutable payloads
@./$(BUILD_DIR)/mocktail_updater status
+56 -33
View File
@@ -6,11 +6,11 @@
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
Mocktail runs the Android `x86_64` Roblox client on Linux. It provides the
Android ABI and JNI pieces the client expects, then uses SDL3 and Vulkan or
OpenGL for the Linux side.
Android ABI and JNI pieces the client expects, then connects them to SDL3 and
Vulkan or OpenGL on the Linux side.
Mocktail is still experimental and may break after a Roblox update. It is not
affiliated with Roblox Corporation or VinegarHQ.
Mocktail is an independent community project. It is not affiliated with Roblox
Corporation or VinegarHQ and does not distribute the Roblox client.
## How it works
@@ -24,8 +24,17 @@ an update fails.
## Install with Flatpak
Install the stable release from Flathub:
```bash
flatpak install --user https://komaruworld.github.io/mocktail/mocktail.flatpakref
flatpak install flathub space.bigrat.mocktail
flatpak run space.bigrat.mocktail
```
Nightly builds are produced automatically from the latest `main` branch:
```bash
flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref
flatpak run space.bigrat.mocktail
```
@@ -44,39 +53,53 @@ Use `mocktail` instead of `mocktail-git` to build the pinned release from
source, or install the stable prebuilt package with `paru -S mocktail-bin` or
`yay -S mocktail-bin`.
## Screenshots
## Install with DNF
<details>
<summary>Flatpak build</summary>
![Roblox home in Mocktail](docs/screenshots/flatpak-home.png)
![Roblox gameplay in Mocktail](docs/screenshots/flatpak-gameplay-tower.png)
![Roblox experience in Mocktail](docs/screenshots/flatpak-gameplay-lobby.png)
</details>
## Other Linux packages
The latest AppImage, DEB, RPM, and Arch packages are attached to the
[continuous release](https://github.com/komaruworld/mocktail/releases/tag/continuous).
Fedora 44 users can install either the stable `mocktail` package or the
`mocktail-nightly` package built from `main`:
```bash
# AppImage
chmod +x Mocktail-x86_64.AppImage
./Mocktail-x86_64.AppImage
# Ubuntu/Debian
sudo apt install ./mocktail_*_amd64.deb
# Fedora
sudo dnf install ./mocktail-*.x86_64.rpm
# Arch Linux
sudo pacman -U ./mocktail-*-x86_64.pkg.tar.zst
sudo curl -fsSL https://mocktail.bigrat.space/rpm/mocktail.repo \
-o /etc/yum.repos.d/mocktail.repo
sudo dnf install mocktail
# or
sudo dnf install mocktail-nightly
```
## Install with APT
Ubuntu 26.04 users can install either the stable `mocktail` package or the
`mocktail-nightly` package built from `main`:
```bash
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://mocktail.bigrat.space/mocktail-packages.gpg \
-o /etc/apt/keyrings/mocktail.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/mocktail.gpg] https://mocktail.bigrat.space/apt mocktail main" | \
sudo tee /etc/apt/sources.list.d/mocktail.list >/dev/null
sudo apt update
sudo apt install mocktail
# or
sudo apt install mocktail-nightly
```
## Direct downloads
Direct AppImage, DEB, and RPM downloads are available from the
[Website](https://mocktail.bigrat.space/) and
[GitHub Releases](https://github.com/komaruworld/mocktail/releases).
<details>
<summary>Screenshots</summary>
![Roblox home in Mocktail](assets/screenshots/flatpak-home.png)
![Roblox gameplay in Mocktail](assets/screenshots/flatpak-gameplay-tower.png)
![Roblox experience in Mocktail](assets/screenshots/flatpak-gameplay-lobby.png)
</details>
## Building
Only Linux `x86_64` is supported. Building requires CMake 3.20+, Git,
File renamed without changes.
+2 -2
View File
@@ -5,7 +5,7 @@ include_guard(GLOBAL)
find_package(PkgConfig REQUIRED)
pkg_check_modules(UTF8PROC REQUIRED IMPORTED_TARGET libutf8proc)
pkg_check_modules(SDL3_TTF REQUIRED IMPORTED_TARGET sdl3-ttf)
find_package(SDL3_ttf REQUIRED CONFIG)
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
get_filename_component(MOCKTAIL_INPUT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.."
@@ -35,7 +35,7 @@ target_link_libraries(mocktail_input_runtime PUBLIC
PkgConfig::UTF8PROC
PRIVATE
PkgConfig::FONTCONFIG
PkgConfig::SDL3_TTF
SDL3_ttf::SDL3_ttf
)
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
mocktail_apply_compile_options(mocktail_input_runtime)
+31
View File
@@ -64,6 +64,32 @@ audio:
# To pin output, copy an exact SDL device name printed during startup.
output_device: default
integrations:
discord_rpc:
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
# This never signs in to Discord and never reads an account token.
enabled: false
# Boolean (default: true): show the current Roblox experience name.
show_place_name: true
# Boolean (default: true): show how long the current session has run.
show_elapsed_time: true
join:
# Boolean (default: true): let friends open the current experience. When
# Roblox provides a public server ID, the button targets that server.
enabled: true
# Boolean (default: true): never expose private or reserved joins.
public_servers_only: true
# button_label: Join Server
# English defaults. Uncomment only the lines you want to customize.
# text:
# browsing: Browsing experiences
# joining: Joining an experience
# playing: "{place_name}"
# state: Playing Roblox
# unknown_place: Unknown experience
# Advanced: overrides the application bundled by the package maintainer.
# application_id: 123456789012345678
window:
# Integer (default: 1280): initial window width in pixels.
width: 1280
@@ -86,6 +112,11 @@ updates:
# backend, and promote only on success. An existing current payload is
# preserved when probation fails.
automatic: true
# Boolean (default: true): testing track. Always try provider latest, even
# when it has no verified payload profile, and do not download or activate
# an older supported payload in its place. Exact HostAbi derivation and two
# canaries are still required before latest can become current.
testing_latest_only: true
# String (default: apk-pure): direct x86_64 APK provider.
source: apk-pure
# Reserved for desktop update integrations. `mocktail_updater` itself never
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Check, canary, and install the latest working Roblox for Mocktail
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=%h/.config/mocktail/updater.env
ExecStart=/usr/bin/env ${MOCKTAIL_UPDATE_HELPER} update
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=default.target
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=Periodically check Roblox updates for Mocktail
[Timer]
OnBootSec=5m
OnUnitActiveSec=6h
RandomizedDelaySec=15m
Persistent=true
Unit=mocktail-update.service
[Install]
WantedBy=timers.target
+2 -12
View File
@@ -19,16 +19,8 @@ namespace mocktail::compat {
using BionicAtForkCallback = void (*)(void);
// Registers Android's __register_atfork callback contract with the host
// pthread runtime. Bionic associates registrations with dso_handle and
// removes them when that DSO unloads. POSIX pthread_atfork has no matching
// unregister operation, so the host bridge accepts dso_handle for ABI
// compatibility but cannot use it for cleanup.
//
// Callback code must remain mapped for every later fork. In practice, a guest
// DSO that has registered callbacks must stay loaded until process exit;
// unloading is safe only when the process guarantees that it will never fork
// again.
// POSIX cannot unregister by dso_handle, so registered guest callbacks must
// remain mapped for every later fork.
int RegisterBionicAtFork(BionicAtForkCallback prepare,
BionicAtForkCallback parent,
BionicAtForkCallback child, void* dso_handle) noexcept;
@@ -37,8 +29,6 @@ int RegisterBionicAtFork(BionicAtForkCallback prepare,
extern "C" {
// Exact LP64 Android/Bionic __register_atfork signature. The prefixed host
// symbol is exported to the guest as "__register_atfork" by synthetic libc.so.
int mocktail_bionic_register_atfork(
mocktail::compat::BionicAtForkCallback prepare,
mocktail::compat::BionicAtForkCallback parent,
+8 -25
View File
@@ -22,11 +22,8 @@
namespace mocktail::compat {
// Android x86-64's malloc.h declares ten size_t fields in this order. This is
// not the host's struct mallinfo: glibc's legacy type uses int,
// and musl exposes no allocator-telemetry API. On hosts without a compatible
// size_t-based telemetry API, BionicMallinfo returns an all-zero "unknown"
// snapshot. Callers must not interpret that fallback as proof of zero usage.
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
// An all-zero fallback means unknown, not zero usage.
struct BionicMallinfoSnapshot {
size_t arena = 0;
size_t ordblks = 0;
@@ -44,10 +41,7 @@ struct BionicLocaleState;
using BionicLocale = BionicLocaleState*;
using BionicThreadDestructor = void (*)(void*);
// Do not use the host socket structs here. Although glibc happens to match
// Android LP64, musl declares cmsg_len and msg_controllen as 32-bit socklen_t.
// Bionic x86-64 uses size_t for both, as audited from the vendored
// bionic/libc/include/sys/socket.h.
// Bionic uses size_t where musl uses 32-bit socklen_t.
struct BionicMessageHeader {
void* msg_name = nullptr;
uint32_t msg_namelen = 0;
@@ -64,35 +58,24 @@ struct BionicControlMessageHeader {
int cmsg_type = 0;
};
// Implements Android's exported CMSG_NXTHDR helper over the explicit Bionic
// x86-64 msghdr/cmsghdr layout above. Malformed or truncated control buffers
// fail closed with nullptr.
// Malformed or truncated control buffers return nullptr.
BionicControlMessageHeader* BionicCmsgNextHeader(
BionicMessageHeader* message, BionicControlMessageHeader* current) noexcept;
// Registers one destructor for the current thread using libstdc++'s Itanium
// C++ ABI implementation. That implementation is present on both supported
// glibc and musl toolchains and preserves destructor execution at thread exit.
// Uses libstdc++'s Itanium ABI on both supported libc implementations.
int BionicCxaThreadAtExit(BionicThreadDestructor destructor, void* argument,
void* dso_handle) noexcept;
// Fills the complete buffer from the Linux kernel RNG. The implementation
// falls back to /dev/urandom only when getrandom is unavailable or blocked and
// aborts rather than returning predictable or partially initialized bytes.
// Falls back to /dev/urandom; never returns partial or predictable bytes.
void BionicArc4RandomBuffer(void* buffer, size_t size) noexcept;
BionicMallinfoSnapshot BionicMallinfo() noexcept;
bool BionicMallinfoHasHostTelemetry() noexcept;
// Formats one Linux errno value using Bionic's POSIX strerror_r contract.
// Unlike glibc's GNU variant, success is reported as zero; truncation returns
// -1 and sets errno to ERANGE while preserving the caller's errno on success.
// POSIX strerror_r contract: zero on success, -1/ERANGE on truncation.
int BionicStrError(int error_number, char* buffer, size_t buffer_size) noexcept;
// Bionic supports only C and C.UTF-8, whose integer parsing is identical. A
// host C locale is installed only for the duration of each call, so ambient
// process locale cannot change guest parsing and a Bionic locale pointer is
// never mistaken for an incompatible host locale_t.
// Parses under host C locale without passing an incompatible Bionic locale_t.
long long BionicStrToLongLongLocale(const char* text, char** end, int base,
BionicLocale locale) noexcept;
unsigned long long BionicStrToUnsignedLongLongLocale(
+4 -13
View File
@@ -23,30 +23,21 @@ namespace mocktail::compat {
using NativeThreadInitializer = void (*)();
// Pinned Bionic LP64 pthread_internal.h keeps total per-thread reservation
// near 1 MiB by subtracting its 32 KiB alternate signal stack from the normal
// thread stack. Host defaults are not ABI substitutes: musl's default is only
// 128 KiB on the supported Void runtime.
// Bionic reserves 32 KiB of its 1 MiB thread allocation for the signal stack.
inline constexpr size_t kBionicLp64DefaultThreadStackSize =
(1U * 1024U * 1024U) - (32U * 1024U);
static_assert(kBionicLp64DefaultThreadStackSize == 1015808U);
// Used only after a Bionic-sized stack is rejected by the host at thread
// creation time. The retry takes the largest of this floor, the requested
// guest size, and the host default. That retains enough stack on musl without
// shrinking glibc's default.
// Retry floor when the host rejects the Bionic-sized stack.
inline constexpr size_t kBionicLp64FallbackThreadStackSize = 2U * 1024U * 1024U;
// Installs the payload-owned per-thread initializer used by subsequently
// created guest threads. Passing nullptr restores ordinary host behavior.
// nullptr restores host behavior.
void ConfigureBionicPthreadThreadInitializer(
NativeThreadInitializer initializer) noexcept;
} // namespace mocktail::compat
// Creates a host thread after rebuilding the supported POSIX attributes.
// Rebuilding avoids passing ABI-private pthread_attr_t state from the Android
// payload directly into glibc.
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void* (*start_routine)(void*),
+2 -3
View File
@@ -31,9 +31,8 @@ constexpr uint32_t kBionicPthreadKeyValidFlag = uint32_t{1} << 31;
using BionicPthreadKeyDestructor = void (*)(void*);
// implementation of Bionic's pthread_key_* ABI. It owns only guest
// keys registered through the synthetic Android libc.so, while ordinary host
// code continues to use glibc pthread keys.
// Implements Bionic's pthread_key_* ABI for keys registered through the
// synthetic libc.so. Host code continues to use glibc pthread keys.
class BionicPthreadKeyRuntime {
public:
static BionicPthreadKeyRuntime& Instance() noexcept;
+1 -1
View File
@@ -62,7 +62,7 @@ enum class NativePreJniBootstrapStatus {
kFailed,
};
// Installs one exact-Build-ID experiment before libroblox constructors run.
// Installs one exact-Build-ID profile before libroblox constructors run.
// The caller owns policy: this function never enables itself from environment
// variables and must only receive a profile selected from the loaded ELF ID.
HostAbiExperimentResult
+1 -1
View File
@@ -81,7 +81,7 @@ struct NativeAllocatorProfile {
inline constexpr size_t kMaxHostBridgeEntries = 8;
inline constexpr size_t kMaxConstructorRunRanges = 26;
// Build-ID-scoped native ABI knowledge for supported and experimental payload
// Build-ID-scoped native ABI knowledge for supported and candidate payload
// profiles. Keeping these addresses outside the legacy startup graph makes the
// version boundary reviewable and independently testable.
struct HostAbiProfile {
+6 -17
View File
@@ -28,11 +28,8 @@ struct FileSha256Result {
explicit operator bool() const noexcept { return error.empty(); }
};
// Computes a lowercase SHA-256 digest without invoking a shell or an external
// utility. The external ABI approval receipt binds exact profile,
// compatibility-manifest, and payload bytes through this implementation; the
// runtime itself is bound by its stable GNU Build ID because installation may
// rewrite RPATH bytes.
// Approval receipts bind the profile, manifest, and payload to this digest.
// Runtime identity uses its Build ID because installation may rewrite RPATH.
FileSha256Result ComputeFileSha256(const std::string& path);
struct ExternalHostAbiProfileRequest {
@@ -55,27 +52,19 @@ struct ExternalHostAbiProfileResult {
explicit operator bool() const noexcept { return error.empty(); }
};
// Loads one exact, payload-bound ABI profile into the process registry. A
// candidate is accepted only by an explicitly authorized canary process. A
// normal launch instead requires a separate approval receipt produced after
// repeated Tier C readiness. Built-in profiles are immutable and always win.
// Candidates require canary authorization; normal launches require an
// approval receipt. Built-in profiles always win.
ExternalHostAbiProfileResult LoadExternalHostAbiProfile(
const ExternalHostAbiProfileRequest& request);
// Environment integration used by the pre-native compatibility gate. An
// unset MOCKTAIL_HOST_ABI_PROFILE_FILE is a successful no-op. If it is set,
// candidate authorization requires MOCKTAIL_HOST_ABI_CANARY=1,
// MOCKTAIL_ALLOW_CANDIDATE_HOST_ABI=1, and the explicit research command-line
// marker. Approved profiles require MOCKTAIL_HOST_ABI_APPROVAL_RECEIPT and
// neither canary environment marker.
// An unset profile is a no-op. Candidates require both canary flags and the
// research CLI marker; approved profiles require a receipt and no canary flag.
ExternalHostAbiProfileResult InitializeHostAbiProfileFromEnvironment(
const std::string& payload_path,
const std::string& compatibility_manifest_path,
const std::string& expected_build_id,
bool explicit_unverified_authorization);
// Registry lookup used by FindHostAbiProfile after its immutable built-in
// table. Callers should use FindHostAbiProfile rather than this function.
const HostAbiProfile* FindLoadedExternalHostAbiProfile(
std::string_view build_id) noexcept;
+15 -98
View File
@@ -12,20 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// jnivm/jnivm.h — Pseudo Java Native Interface Virtual Machine.
//
// Provides a lightweight C++ implementation of a JVM stub for use with
// Android-native shared libraries (e.g., libroblox.so) running on Linux.
// No real Java bytecode is executed; instead, all JNI calls are intercepted
// and dispatched to registered C++ callbacks.
//
// Usage:
// auto vm = std::make_shared<jnivm::VM>();
// auto cls = vm->RegisterClass("android/content/Context");
// cls->RegisterMethod("getSystemService",
// "(Ljava/lang/String;)Ljava/lang/Object;",
// [](JNIEnv*, jobject, ...) { return nullptr; });
#ifndef SOBER_TEST_JNIVM_JNIVM_H_
#define SOBER_TEST_JNIVM_JNIVM_H_
@@ -42,29 +28,20 @@
namespace jnivm {
// Forward declarations
class VM;
class Class;
// Object — base type for all pseudo-Java objects managed by the pseudo-JVM.
// Analogous to java.lang.Object in the real JVM.
class Object {
public:
explicit Object(std::shared_ptr<Class> klass) : klass_(std::move(klass)) {}
virtual ~Object() = default;
// Returns the pseudo-class descriptor of this object.
std::shared_ptr<Class> GetClass() const { return klass_; }
private:
std::shared_ptr<Class> klass_;
};
// MethodCallback — signature for registered JNI method stubs.
// The callback receives the raw JNIEnv pointer and the receiver jobject.
using MethodCallback = std::function<void(JNIEnv *, jobject)>;
// Resolved Roblox account identity supplied by the host runtime. This
@@ -75,8 +52,6 @@ struct RobloxAuthIdentity {
std::string display_name;
};
// Host device capabilities exposed through the pseudo Android framework.
// The guest ABI remains Android, while the default profile // describes a Linux desktop with physical mouse and keyboard input.
struct PlatformIdentity {
bool touch_enabled = false;
bool mouse_enabled = true;
@@ -99,23 +74,16 @@ struct RobloxCredentialView {
using RobloxCredentialProvider = RobloxCredentialView (*)(const void *context);
// Creates a Configuration object consistent with the VM's immutable platform
// identity snapshot. This is the replacement for legacy construction of
// contradictory Android keyboard/touch fields.
jobject CreateAndroidConfiguration(JNIEnv *env);
// Narrow write-only boundary for credentials produced by Roblox after the
// native runtime has started. A successfully persisted value supersedes the
// bound provider for the remainder of the current VM lifetime so native sign-in
// can become authoritative without reopening an untrusted credential source.
// A stored credential replaces the bound provider for the rest of this VM's
// lifetime.
struct RobloxCredentialSinkCallbacks {
bool (*store)(void *context, const char *data, std::size_t size) = nullptr;
};
// Narrow host callback boundary for org/fmod/AudioDevice. The pseudo-JVM owns
// only an opaque shared context and never depends on the SDL audio runtime.
// Each Java receiver is passed as a stable identity so the host can keep
// independent device lifecycles without retaining a JNI local reference.
// The VM keeps the host context opaque. Receiver identities let the backend
// track devices without retaining JNI local references.
struct FmodAudioDeviceCallbacks {
bool (*init)(void *context, const void *identity, int channels,
int sample_rate_hz, int block_size_frames,
@@ -126,17 +94,12 @@ struct FmodAudioDeviceCallbacks {
void (*shutdown)(void *context) = nullptr;
};
// Narrow host callback boundary for the activity.setWindowFlags(II)V contract.
// Production LuaApp retains its activity through the android.app.Activity
// interface, while GameActivity startup uses the concrete subclass. Guest JNI
// threads only publish Android flags; the platform callback must marshal any
// SDL work onto the SDL main thread.
// Guest JNI threads only publish Android window flags. The host callback must
// marshal SDL work onto the main thread.
struct AndroidWindowCallbacks {
bool (*set_flags)(void *context, int flags, int mask) = nullptr;
};
// Immutable snapshot of the Android NativeTextBoxInfo payload supplied by
// Roblox when a native text field receives focus.
struct RobloxTextBoxInfo {
float x = 0.0f;
float y = 0.0f;
@@ -161,8 +124,7 @@ struct RobloxTextInputShowRequest {
RobloxTextBoxInfo info;
};
// Narrow callback boundary for the Roblox native text-input callbacks. The
// pseudo-JVM snapshots all JNI-owned arguments before entering this boundary.
// JNI-owned arguments are copied before the host callback runs.
struct RobloxTextInputCallbacks {
void (*show)(void *context,
const RobloxTextInputShowRequest &request) = nullptr;
@@ -172,9 +134,8 @@ struct RobloxTextInputCallbacks {
void (*shutdown)(void *context) = nullptr;
};
// Narrow boundary for a native MessageBus RawCallback object. The VM
// retains context while a callback is in flight and dispatches only the exact
// run(Ljava/lang/String;)V method on the exact created receiver.
// Dispatch accepts run(String) only on a receiver created by this VM. The
// context stays alive while the callback runs.
struct MessageBusRawCallbacks {
void (*run)(void *context, JNIEnv *env, jstring message) = nullptr;
};
@@ -183,18 +144,14 @@ struct MessageBusRequestHandlerCallbacks {
std::string (*run)(void *context, JNIEnv *env, jstring message) = nullptr;
};
// Narrow boundary for the current APK's legacy BrowserService transport.
// Dispatch accepts only Callback.onItemSet(Ljava/lang/String;)V on the exact
// pseudo receiver created by CreateMemStorageCallback.
// Dispatch accepts Callback.onItemSet(String) only on a receiver created by
// this VM. The context stays alive while the callback runs.
struct MemStorageCallbackCallbacks {
void (*on_item_set)(void *context, JNIEnv *env, jstring value) = nullptr;
};
// Narrow boundary for the exact APK callbacks that update host web
// state: NativeGLJavaInterface notifications, web activities, cookie sync,
// and JNICookieProtocol.OnSetCookieHandler. The VM retains the shared callback
// context while a callback is in flight; consumers must copy JNI strings
// before returning.
// Web callbacks share a retained context while in flight. Consumers must copy
// JNI strings before returning.
struct RobloxDataModelNotificationCallbacks {
void (*on_notification)(void *context, JNIEnv *env, jstring type,
jstring data) = nullptr;
@@ -209,37 +166,22 @@ struct RobloxDataModelNotificationCallbacks {
jstring url) = nullptr;
};
// Narrow boundary for the exact NativeHelper lifecycle notification
// emitted when Roblox returns from an experience to LuaApp.
struct RobloxExperienceLifecycleCallbacks {
void (*on_lua_app_did_return)(void *context) = nullptr;
};
// Class — represents a pseudo Java class registered in the pseudo-JVM.
// Stores the class name (JNI descriptor format) and a map of method stubs.
class Class {
public:
explicit Class(std::string name) : name_(std::move(name)) {}
// Returns the JNI-style class descriptor, e.g. "android/content/Context".
const std::string &GetName() const { return name_; }
// Registers a JNI method stub.
//
// Args:
// method_name: Simple method name (e.g. "getSystemService").
// signature: JNI type descriptor (e.g. "(Ljava/lang/String;)V").
// callback: C++ lambda invoked when the method is called via JNI.
void RegisterMethod(const std::string &method_name,
const std::string &signature, MethodCallback callback);
// Looks up a method stub by name and signature.
// Returns nullptr if not found.
const MethodCallback *FindMethod(const std::string &method_name,
const std::string &signature) const;
// Returns all registered methods (name → callback).
const std::unordered_map<std::string, MethodCallback> &GetMethods() const {
return methods_;
}
@@ -247,57 +189,37 @@ public:
private:
std::string name_;
// Key format: "methodName:descriptor"
std::unordered_map<std::string, MethodCallback> methods_;
};
// VM — the top-level pseudo Java Virtual Machine.
//
// Owns a registry of pseudo-classes and exposes a JavaVM* compatible
// pointer for passing to JNI_OnLoad in the loaded shared library.
class VM {
public:
VM();
~VM();
// Disallow copy and assign.
VM(const VM &) = delete;
VM &operator=(const VM &) = delete;
// Registers a new pseudo-class by its JNI descriptor.
// Repeated registration returns the existing instance.
//
// Args:
// class_name: JNI-style descriptor, e.g.
// "com/roblox/client/RobloxActivity".
//
// Returns:
// Shared pointer to the registered (or pre-existing) Class object.
// Repeated registration returns the existing class.
std::shared_ptr<Class> RegisterClass(const std::string &class_name);
// Finds a previously registered class.
// Returns nullptr if the class has not been registered.
std::shared_ptr<Class> FindClass(const std::string &class_name) const;
// Returns the raw JavaVM pointer compatible with JNI_OnLoad signature.
JavaVM *GetJavaVM() { return java_vm_; }
// Resolves the process 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);
// Returns the attached JNIEnv pointer for the current host thread.
JNIEnv *GetJNIEnv();
// Returns the number of registered classes.
std::size_t GetClassCount() const { return class_registry_.size(); }
// Replaces the resolved account identity with an independent copy. Invalid
// (non-positive) identities are normalized to the unresolved state.
void SetRobloxAuthIdentity(const RobloxAuthIdentity &identity);
// Returns the VM to its unresolved account state.
void ClearRobloxAuthIdentity();
// Returns an immutable-by-value snapshot safe for use by JNI callbacks on
@@ -405,12 +327,10 @@ public:
void ClearRobloxExperienceLifecycleCallbacks();
bool DispatchRobloxExperienceLuaAppDidReturn();
// Restores env->functions to &native_interface_ in case libroblox replaced
// it.
// Restores the pseudo-JNI table after guest code replaces env->functions.
void RestoreFunctions();
private:
// Internal JNI function tables populated with stub implementations.
JNIInvokeInterface_ invoke_interface_ = {};
JavaVM java_vm_storage_ = {};
JavaVM *java_vm_ = nullptr;
@@ -419,7 +339,6 @@ private:
JNIEnv jni_env_storage_ = {};
JNIEnv *jni_env_ = nullptr;
// Registry of pseudo-classes indexed by their JNI descriptor.
std::unordered_map<std::string, std::shared_ptr<Class>> class_registry_;
mutable std::mutex roblox_auth_identity_mutex_;
@@ -498,8 +417,6 @@ private:
std::shared_ptr<RobloxExperienceLifecycleBinding>
roblox_experience_lifecycle_binding_;
// Initialises the JNIInvokeInterface_ and JNINativeInterface_ function tables
// with stub implementations that forward calls to this VM instance.
void InitJNIFunctionTables();
};
+1 -1
View File
@@ -86,7 +86,7 @@ class RuntimeDependencies final {
ShutdownBeforePlatformCallback shutdown_before_platform_ = nullptr;
};
// Transitional reverse-engineering runtime. New subsystems must expose // interfaces and be composed outside this implementation.
// Transitional runtime kept for the reverse-engineered startup path.
int Run(const runtime::CommandLineOptions& options,
RuntimeDependencies dependencies);
+4 -33
View File
@@ -12,17 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// libc_shim/libc_shim.h — Bionic libc → Glibc call translation layer.
//
// Intercepts calls made by Bionic-compiled code (libroblox.so) to
// Bionic-specific libc functions and forwards them to their Glibc equivalents
// on the host Linux system.
//
// Key responsibilities:
// - pthread_mutex_t size/layout differences between Bionic and Glibc.
// - Virtual filesystem path mapping (APK sandbox → Flatpak paths).
// - Bionic-only syscall wrappers missing in Glibc.
#ifndef SOBER_TEST_LIBC_SHIM_LIBC_SHIM_H_
#define SOBER_TEST_LIBC_SHIM_LIBC_SHIM_H_
@@ -57,10 +46,8 @@ struct HostCaBundleResolution {
// boundary as the caller that will later free them.
void ConfigureGuestAllocator(GuestAllocator allocator) noexcept;
// Installs all shim hooks. Must be called before the target .so is loaded.
//
// After this call, dlopen(3) symbol resolution for known Bionic symbols will
// be intercepted and redirected to the shim implementations.
// Must run before loading the target library so its Bionic imports resolve to
// these shims.
void Install();
// Resolves the host TLS trust bundle without weakening certificate
@@ -76,29 +63,13 @@ HostCaBundleResolution ConfigureHostCaBundlePathMappings();
const char* HostCaBundleStatusName(HostCaBundleStatus status);
// Maps an Android-style virtual path to its Flatpak sandbox equivalent.
//
// Example:
// "/data/user/0/com.roblox.client/files/logs" →
// "/home/user/.var/app/org.vinegarhq.Sober/data/sober/logs"
//
// Args:
// android_path: Absolute path as seen by the Bionic library.
//
// Returns:
// Translated host path. Returns android_path unchanged if no mapping
// is registered for it.
// Applies the longest matching Android-to-host path prefix. Unmapped paths are
// returned unchanged.
std::string TranslatePath(const std::string& android_path);
// Registers a path prefix mapping used by TranslatePath.
//
// Args:
// android_prefix: Android path prefix to intercept.
// host_prefix: Replacement prefix on the host filesystem.
void RegisterPathMapping(const std::string& android_prefix,
const std::string& host_prefix);
// Removes all registered path mappings. Primarily for use in tests.
void ClearPathMappings();
} // namespace libc_shim
+5 -67
View File
@@ -12,18 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// linker/linker.h — ELF Bionic-compatible shared library loader.
//
// Provides a minimal interface for loading Android ELF shared libraries
// (.so files compiled for Bionic libc / Android ABI) into a Linux process
// address space, resolving dependencies and exporting a symbol table.
//
// Design notes:
// - Uses dlopen(3) under the hood for system libraries.
// - For Bionic-ABI libraries (libroblox.so), manual ELF parsing and
// mmap(2)-based loading is required; this header exposes that interface.
// - All paths follow POSIX conventions.
#ifndef MOCKTAIL_LINKER_LINKER_H_
#define MOCKTAIL_LINKER_LINKER_H_
@@ -34,9 +22,7 @@
namespace linker {
// Opaque handle returned by LoadLibrary. Callers must not interpret the
// pointer value; it is only valid as an argument to ResolveSymbol or
// UnloadLibrary.
// Opaque; valid only with this linker's resolve/unload functions.
using LibraryHandle = void*;
using SymbolMap = std::unordered_map<std::string, void*>;
using AndroidLibraryLoadObserver = bool (*)(void* context,
@@ -50,59 +36,27 @@ struct ProgramHeaderValidation {
explicit operator bool() const noexcept { return valid; }
};
// AddSyntheticLibrary — registers one Android SONAME and only the exports
// owned by that library. Dependencies of subsequently loaded Android ELF
// files resolve these symbols through mcpelauncher-linker.
LibraryHandle AddSyntheticLibrary(const std::string& soname,
const SymbolMap& exports);
// RegisterSyntheticSymbol — stages or adds one export to a specific Android
// SONAME. If the synthetic library is already loaded, its symbol table is
// updated in place.
// Updates an already loaded library in place.
void RegisterSyntheticSymbol(const std::string& soname,
const std::string& name, void* address);
// Registers Android Bionic thread-local and synchronization ABIs only
// for synthetic libc.so. Host code keeps its glibc implementations.
// These adapters belong to their synthetic SONAME, never the host namespace.
void RegisterBionicPthreadKeyRuntimeForLibc();
// Registers Android's DSO-aware __register_atfork entrypoint only for
// synthetic libc.so. The host pthread bridge preserves callback order but
// cannot unregister callbacks when a guest DSO unloads.
void RegisterBionicAtForkRuntimeForLibc();
// Registers Android LP64 positioned I/O and seek entrypoints only for
// synthetic libc.so. The bridge relies on the x86-64 host's 64-bit
// off_t contract and never exposes host large-file feature-macro aliases.
void RegisterBionicLargeFileRuntimeForLibc();
// Registers host-independent implementations of Android libc exports that
// are not uniformly supplied by glibc and musl. Every symbol remains owned by
// synthetic libc.so and preserves the Android x86-64 ABI.
void RegisterBionicHostLibcRuntimeForLibc();
// Registers Bionic sysconf name translation only for synthetic libc.so.
// Bionic and glibc use different integer values for sysconf names.
void RegisterBionicSysconfRuntimeForLibc();
// Registers the Android LP64 sigaction layout adapter only for libc.so.
void RegisterBionicSignalRuntimeForLibc();
// Registers the LP64 Bionic FILE/__sF ABI and host stdio translation only for
// synthetic libc.so.
void RegisterBionicStdioRuntimeForLibc();
// Restores mcpelauncher-linker's own libdl exports after legacy host-symbol
// discovery. Android dlopen/dlsym must never escape into glibc.
// Android dlopen/dlsym must not escape into glibc.
void RegisterBionicDynamicLoaderForLibdl();
// GetSyntheticLibrarySymbols — returns a snapshot of one SONAME's exports.
// The copy is safe to inspect while other libraries are being registered.
SymbolMap GetSyntheticLibrarySymbols(const std::string& soname);
// UpdateAndroidLibraryPath — configures the Bionic linker's dependency search
// path. Multiple directories use the same colon-separated syntax as
// LD_LIBRARY_PATH.
void UpdateAndroidLibraryPath(const std::string& search_path);
// Installs the single process owner that validates or adapts a real Android
@@ -112,18 +66,12 @@ bool SetAndroidLibraryLoadObserver(AndroidLibraryLoadObserver observer,
void* context);
void ClearAndroidLibraryLoadObserver(void* context);
// OpenAndroidLibrary — loads a real Android ELF and registers its handle under
// logical_name. The containing directory is added to the Bionic dependency
// search path before loading.
LibraryHandle OpenAndroidLibrary(const std::string& real_path,
const std::string& logical_name);
// Legacy compatibility bridge. New code should use AddSyntheticLibrary or
// OpenAndroidLibrary so real files and synthetic SONAMEs cannot be confused.
LibraryHandle LoadLibrary(const std::string& real_path,
const std::string& host_alias);
// ResolveSymbol — looks up an exported symbol in a loaded library.
void* ResolveSymbol(LibraryHandle handle, const std::string& symbol_name);
// Crashpad execution is intentionally outside Mocktail's supported JNI
@@ -132,24 +80,15 @@ void* ResolveSymbol(LibraryHandle handle, const std::string& symbol_name);
bool IsBlockedRobloxCrashReportNativeSymbol(
std::string_view symbol_name) noexcept;
// UnloadLibrary — releases resources associated with a loaded library.
void UnloadLibrary(LibraryHandle handle);
// Legacy compatibility bridge used by the current monolithic startup path.
// New code should use RegisterSyntheticSymbol and assign every export to its
// owning SONAME.
void RegisterSymbol(const std::string& name, void* addr);
// Returns the legacy accumulated symbol table. It remains available until the
// existing startup path is migrated to per-SONAME maps.
const SymbolMap& GetBionicSymbols();
// Returns mcpelauncher-linker's Bionic dl_iterate_phdr entrypoint. Android
// unwinders must not receive glibc's host implementation/layout.
// Android unwinders must not receive glibc's implementation or layout.
void* BionicDlIteratePhdrAddress();
// Confirms that the Bionic program-header iterator can see a manually loaded
// Android ELF, including the unwind metadata needed for C++ exceptions.
ProgramHeaderValidation ValidateBionicProgramHeaders(LibraryHandle handle);
// Returns the mapped ELF base for a loaded Android library alias. The lookup
@@ -157,7 +96,6 @@ ProgramHeaderValidation ValidateBionicProgramHeaders(LibraryHandle handle);
// alias or a host-dlopen library without Bionic image metadata.
std::uintptr_t FindLoadedAndroidLibraryBase(const std::string& logical_name);
// SymbolTable — global registry of alias → handle mappings.
const std::unordered_map<std::string, LibraryHandle>& GetSymbolTable();
} // namespace linker
+8 -15
View File
@@ -22,9 +22,7 @@
namespace mocktail::audio {
// Interleaved PCM formats accepted at the host audio boundary. Endianness is
// explicit because Android x86-64 producers use little-endian PCM regardless
// of the host implementation chosen by SDL.
// Android producers supply little-endian interleaved PCM.
enum class PcmSampleFormat {
kUnsigned8,
kSigned16LittleEndian,
@@ -41,11 +39,9 @@ struct PcmSpec {
using AudioBufferReleaseCallback = void (*)(void* context, const void* data,
std::size_t size_bytes);
// A PCM submission. With no release callback, Enqueue() copies the bytes
// before returning. With a release callback, the sink may borrow the buffer;
// the caller must keep it valid until the callback runs. A successful
// submission invokes the callback exactly once, including when Clear() or
// Shutdown() discards queued audio. A failed submission never invokes it.
// Without a callback Enqueue copies the bytes. With one, a successful Enqueue
// borrows them until exactly one release callback, including on Clear or
// Shutdown. On failure the callback is not invoked.
struct PcmBuffer {
const void* data = nullptr;
std::size_t size_bytes = 0;
@@ -57,9 +53,7 @@ Status ValidatePcmSpec(const PcmSpec& spec);
std::size_t BytesPerSample(PcmSampleFormat format);
std::size_t BytesPerFrame(const PcmSpec& spec);
// Thread-safe PCM playback contract. Implementations perform device format
// conversion and resampling through their host audio library; callers always
// submit data in source_spec().
// Thread-safe; callers submit source_spec(), and the backend converts it.
class AudioSink {
public:
virtual ~AudioSink() = default;
@@ -68,15 +62,14 @@ class AudioSink {
virtual Status Enqueue(const PcmBuffer& buffer) = 0;
virtual Status Pause() = 0;
virtual Status Resume() = 0;
// Linear gain is delegated to the host audio library. Zero mutes, one is
// unity gain, and negative or non-finite values are rejected.
// Zero mutes; one is unity; negative and non-finite values are invalid.
virtual Status SetGain(float linear_gain) = 0;
virtual Status Flush() = 0;
virtual Status Clear() = 0;
virtual Status GetQueuedBytes(std::size_t* size_bytes) const = 0;
// Idempotent. It waits for in-flight API calls and releases all borrowed
// buffers before returning. Calls started after shutdown fail closed.
// Idempotent. Waits for in-flight calls and borrowed buffers; later calls
// fail closed.
virtual void Shutdown() = 0;
};
@@ -23,14 +23,10 @@
namespace mocktail::audio {
// org.fmod.AudioDevice is an ordinary Java object. The JNI boundary passes
// its jobject value through this opaque key without making the audio layer
// depend on JNI headers or retain a JNI reference.
// Opaque jobject identity; the audio layer never retains a JNI reference.
using FmodJavaAudioDeviceIdentity = const void*;
// A factory returns a paused sink with exactly source_spec. Production uses
// SDL3; tests can inject a deterministic sink without opening a host device.
// factory_context must remain valid until the runtime is shut down.
// Returns a paused sink. factory_context must outlive the runtime.
using FmodJavaAudioSinkFactory = Status (*)(
void* factory_context, const PcmSpec& source_spec,
std::unique_ptr<AudioSink>* sink);
@@ -44,8 +40,7 @@ struct FmodJavaAudioRuntimeOptions {
std::size_t max_buffer_bytes_per_device = 64U * 1024U * 1024U;
};
// Content-free counters suitable for readiness logs. They never expose PCM,
// JNI identities, or guest pointers.
// Contains no PCM, JNI identities, or guest pointers.
struct FmodJavaAudioRuntimeStats {
std::uint64_t init_attempts = 0;
std::uint64_t initialized_devices = 0;
@@ -65,18 +60,9 @@ struct FmodJavaAudioRuntimeStats {
std::size_t pending_bytes = 0;
};
// Thread-safe implementation of the APK's exact Java playback contract:
//
// AudioDevice.init(channels, sampleRate, blockSize, blockCount)Z
// AudioDevice.write(byte[], length)V
// AudioDevice.close()V
//
// Init() allocates blockCount fixed slots of blockSize frames. Every successful
// Write() copies guest bytes synchronously into one slot, then lends that stable
// storage to AudioSink without a second PCM copy. FmodJavaAudioRuntime performs
// no per-write allocation for buffer storage or release ownership. A producer
// waits with AudioTrack-style blocking backpressure when all slots are borrowed
// instead of growing memory or dropping a block.
// Fixed slots avoid per-write allocation. Write copies into a slot, then lends
// it to AudioSink; a full pool applies blocking backpressure instead of
// growing memory or dropping audio.
class FmodJavaAudioRuntime final {
public:
explicit FmodJavaAudioRuntime(
@@ -94,8 +80,7 @@ class FmodJavaAudioRuntime final {
FmodJavaAudioRuntimeStats GetStats() const;
// Idempotent. Prevents new devices/writes, shuts down every sink, and waits
// for every borrowed owned buffer to be released before returning.
// Waits for every borrowed buffer to be released.
void Shutdown();
private:
+7 -22
View File
@@ -25,9 +25,7 @@
namespace mocktail::audio {
// SDL documents subsystem initialization and shutdown as main-thread-only.
// The process composition root owns this lifetime; audio workers only create
// streams while the subsystem is active.
// SDL requires subsystem startup and shutdown on the main thread.
Status InitializeSdlAudioSubsystem();
Status ShutdownSdlAudioSubsystem();
@@ -36,50 +34,37 @@ struct SdlPlaybackDevice {
std::string name;
};
// Lists physical SDL playback devices. The abstract `default` target is not a
// physical entry and is therefore intentionally absent from this vector.
// The virtual `default` target is not returned.
Status ListSdlPlaybackDevices(std::vector<SdlPlaybackDevice>* devices);
// Resolves `default` or one exact physical device name. This pure selection
// step is separate from SDL enumeration so configuration behavior is directly
// testable.
Status ResolveSdlPlaybackDevice(
std::string_view requested,
const std::vector<SdlPlaybackDevice>& available_devices,
std::uint32_t* playback_device_id, std::string* resolved_name);
// Sets the process playback target used by every subsequently created FMOD
// Java and OpenSL sink. It must run after SDL audio initialization and before
// the first sink is opened.
// Must run after SDL audio initialization and before the first sink opens.
Status ConfigureSdlPlaybackDevice(
std::string_view requested,
std::vector<SdlPlaybackDevice>* available_devices,
std::string* resolved_name);
// Returns the process playback target currently used by new sinks. The
// physical ID is zero for SDL's system-default route.
// Device ID zero means the system default.
Status GetConfiguredSdlPlaybackDevice(std::uint32_t* playback_device_id,
std::string* resolved_name);
// Atomically migrates every live FMOD Java and OpenSL stream to one physical
// SDL device, or to the system default when playback_device_id is zero. New
// sinks inherit the same target. Existing queued PCM stays in its stream.
// Migrates live streams without dropping queued PCM. New sinks inherit it.
Status SwitchSdlPlaybackDevice(std::uint32_t playback_device_id,
std::string* resolved_name);
struct SdlAudioSinkOptions {
PcmSpec source_spec;
// Zero selects the process playback target configured above (the SDL
// default until explicitly configured). A nonzero ID bypasses that target.
// Zero selects the configured process target.
std::uint32_t playback_device_id = 0;
bool start_paused = true;
};
// Opens a real SDL 3.4+ playback device and its bound SDL_AudioStream. The SDL
// audio subsystem must already be owned by InitializeSdlAudioSubsystem(). SDL
// owns all format conversion, resampling, and borrowed-buffer consumption.
// On failure, *sink remains null.
// SDL owns conversion, resampling, and borrowed-buffer consumption.
Status CreateSdlAudioSink(const SdlAudioSinkOptions& options,
std::unique_ptr<AudioSink>* sink);
@@ -26,9 +26,7 @@
namespace mocktail {
namespace graphics {
// Vulkan VkResult values are a stable signed 32-bit ABI. Keeping the Android
// boundary independent of the optional Vulkan SDK lets capability-only builds
// continue to compile with SDL's opaque Vulkan handles.
// Stable signed 32-bit ABI, independent of the optional Vulkan SDK.
using AndroidVulkanResult = VkResult;
inline constexpr AndroidVulkanResult kAndroidVulkanSuccess =
@@ -43,43 +41,29 @@ inline constexpr std::uint64_t kHostImageAcquireWatchdogTimeoutNs =
inline constexpr char kAndroidSurfaceExtension[] =
"VK_KHR_android_surface";
// Desktop compositors can report a valid acquired/presented image as
// suboptimal while a fullscreen configure settles. The Android client path
// used by Roblox treats that positive success code as a hard error, so the
// compatibility boundary exposes it as the equivalent VK_SUCCESS result.
// Roblox treats VK_SUBOPTIMAL_KHR as a hard error, so expose it as success.
VkResult NormalizeAndroidSwapchainResult(VkResult result);
// Android requests an infinite image-acquire wait, but a lost Wayland/X11
// compositor wakeup would then block Roblox's RenderJob forever. Only the
// infinite guest request is bounded; explicit finite Vulkan timeouts retain
// their original contract.
// 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 fence/timeline waits retain their guest-visible semantics, but are
// issued to the host in finite slices so a stalled renderer produces evidence
// while it remains blocked. VK_TIMEOUT from a slice is never returned to the
// Android client as a completed wait.
// 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);
// Rewrites an Android VkInstance extension request for the host WSI. The
// returned storage owns its strings and can be used to build the
// ppEnabledExtensionNames array passed to the host vkCreateInstance call.
// Returned storage owns the translated extension names.
Status TranslateAndroidVulkanInstanceExtensions(
const std::vector<std::string>& android_extensions,
const std::vector<std::string>& host_wsi_extensions,
std::vector<std::string>* host_extensions);
// Android WSI boundary. vkCreateAndroidSurfaceKHR-compatible exports can
// delegate here after vkCreateInstance has used the translated extension list.
// The Android native-window member is intentionally ignored: SDL owns the real
// host window and is the only component allowed to create its VkSurfaceKHR.
// Ignores the guest native-window field; SDL alone creates the host surface.
class AndroidVulkanWsiAdapter final {
public:
using PresentCallback = void (*)(void* user_data);
+3 -11
View File
@@ -12,14 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Exact-path loader for Mocktail's Bionic libEGL adapter.
//
// A host EGL implementation may have the Android SONAME "libEGL.so" too
// (Chromium ANGLE does). Loading by SONAME after SDL/ANGLE starts can then
// return the host implementation and make Android code create a second EGL
// surface for one native window. This adapter opens the Mocktail
// bridge beside the executable and exposes only its EGL exports for the
// synthetic Bionic libEGL.so dependency.
// Loading by SONAME can select host ANGLE instead of the Bionic adapter. This
// bridge therefore opens the library beside the executable by exact path.
#ifndef MOCKTAIL_GRAPHICS_BIONIC_EGL_BRIDGE_H_
#define MOCKTAIL_GRAPHICS_BIONIC_EGL_BRIDGE_H_
@@ -37,9 +31,7 @@ public:
BionicEglBridge(const BionicEglBridge &) = delete;
BionicEglBridge &operator=(const BionicEglBridge &) = delete;
// Loads $ORIGIN/libEGL.so by its exact absolute path. On success, the
// handle intentionally remains live for the process lifetime because the
// Bionic linker retains the exported function addresses.
// The handle stays live because the Bionic linker retains its exports.
bool Load();
bool IsLoaded() const { return handle_ != nullptr; }
+2 -3
View File
@@ -30,9 +30,8 @@
namespace mocktail {
namespace graphics {
// Thin host WSI scaffold. It does not create a VkInstance, device, swapchain,
// renderer, or allocator. A future Android Vulkan adapter may translate
// vkCreateAndroidSurfaceKHR into CreateSurface after rewriting extensions.
// Provides SDL's host WSI entry points. Instance, device, swapchain, renderer,
// and allocator ownership stays with the caller.
class SdlVulkanWsi final {
public:
SdlVulkanWsi() = default;
+4 -10
View File
@@ -18,19 +18,15 @@
#include <cstddef>
#include <cstdint>
// Stable process-local ABI between the host input runtime in the executable
// and libvulkan.so, which is loaded as a separate Android compatibility DSO.
// Pixels are tightly packed RGBA8. The producer owns and securely clears its
// storage; consumers must copy a matching revision before returning.
// Process-local ABI with libvulkan.so. Pixels are RGBA8; consumers must copy a
// matching revision before the producer clears its storage.
struct MocktailTextOverlayFrameInfo {
static constexpr std::uint32_t kAbiVersion = 2;
std::uint32_t abi_version = kAbiVersion;
std::uint32_t visible = 0;
std::uint64_t revision = 0;
// x/y/width/height use the host's logical input coordinate space. These
// dimensions let a Vulkan consumer map that space to the swapchain's pixel
// extent without assuming a 1:1 high-DPI scale.
// Coordinates are logical, not swapchain pixels.
std::uint32_t coordinate_width = 0;
std::uint32_t coordinate_height = 0;
std::int32_t x = 0;
@@ -43,9 +39,7 @@ struct MocktailTextOverlayFrameInfo {
extern "C" {
// Lock-free conservative hint for the Vulkan present fast path. A false
// result guarantees that no drawable overlay is currently published. A true
// result only means that the synchronized query/copy path must decide.
// false guarantees no overlay; true still requires a synchronized query.
bool mocktail_text_overlay_may_present();
bool mocktail_text_overlay_query(MocktailTextOverlayFrameInfo* frame);
bool mocktail_text_overlay_copy(std::uint64_t revision, void* rgba,
@@ -22,13 +22,8 @@
namespace mocktail {
namespace graphics {
// Composites the process-local RGBA text surface into application-owned
// swapchain images. The adapter retains ownership of every Vulkan object it
// registers. DestroySwapchain and DestroyDevice must be called before the
// corresponding host Vulkan destroy function.
//
// An unsupported device or swapchain remains a valid registration failure:
// QueuePresent then forwards the unmodified VkPresentInfoKHR to fallback.
// Registered Vulkan objects remain caller-owned and must be unregistered
// before host destruction. Unsupported registrations fall back cleanly.
class VulkanTextOverlayCompositor final {
public:
VulkanTextOverlayCompositor();
@@ -38,10 +33,8 @@ class VulkanTextOverlayCompositor final {
VulkanTextOverlayCompositor& operator=(const VulkanTextOverlayCompositor&) =
delete;
// enabled_features describes the features actually enabled by
// vkCreateDevice, not merely the features supported by the physical device.
// The supplied proc-address callbacks must bypass the Android-facing Vulkan
// adapter and resolve host functions directly.
// enabled_features must match vkCreateDevice. Proc callbacks resolve host
// functions directly, bypassing the Android adapter.
bool RegisterDevice(VkInstance instance, VkPhysicalDevice physical_device,
VkDevice device, std::uint32_t api_version,
const char* const* enabled_extensions,
@@ -61,10 +54,7 @@ class VulkanTextOverlayCompositor final {
const VkImage* images, std::uint32_t image_count);
void DestroySwapchain(VkDevice device, VkSwapchainKHR swapchain);
// Serializes adapter-mediated submit, sparse-bind, present, and idle
// operations on the exact queue range imported by libplacebo. Other
// VkQueues remain independent, including the inactive-overlay fast path on
// a non-imported queue.
// Adapter operations serialize only queues imported by libplacebo.
VkResult QueueSubmit(VkQueue queue, std::uint32_t submit_count,
const VkSubmitInfo* submits, VkFence fence,
PFN_vkQueueSubmit fallback);
@@ -77,10 +67,7 @@ class VulkanTextOverlayCompositor final {
VkResult QueueWaitIdle(VkQueue queue, PFN_vkQueueWaitIdle fallback);
VkResult DeviceWaitIdle(VkDevice device, PFN_vkDeviceWaitIdle fallback);
// Always invokes fallback when it is non-null. The original wait semaphores
// are forwarded unchanged until the compositor successfully submits the
// wait-collapse bridge; after that point only compositor-owned semaphores
// are passed to fallback.
// After wait collapse, fallback receives only compositor-owned semaphores.
VkResult QueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info,
PFN_vkQueuePresentKHR fallback);
+8 -23
View File
@@ -40,9 +40,7 @@ enum class AuthRuntimeStatus {
kUnavailable,
};
// Process-local ownership for the canonical .ROBLOSECURITY header. The class
// is move-only and clears its backing allocation before release.
// Callers must never log the value returned by view() or c_str().
// Move-only credential storage that clears its allocation. Never log its view.
class SecureRobloxCredential final {
public:
SecureRobloxCredential() = default;
@@ -64,14 +62,10 @@ class SecureRobloxCredential final {
std::vector<char> bytes_;
};
// Clears a transient string that contained derived credential material.
void SecurelyClearString(std::string* value);
// Binds the production pseudo-VM to the exact credential resolved by the
// authentication preflight. The binding is intentionally non-movable:
// it must be created only after its credential reaches its final address and
// destroyed before that credential or the VM. Guest credentials block legacy
// env/disk fallback until the sink durably accepts a native sign-in value.
// Must be created after the credential reaches its final address and destroyed
// before the credential or VM. Guest bindings block legacy env/disk fallback.
class ScopedRobloxCredentialBinding final {
public:
ScopedRobloxCredentialBinding(jnivm::VM* jni_vm,
@@ -93,9 +87,7 @@ class ScopedRobloxCredentialBinding final {
jnivm::VM* jni_vm_ = nullptr;
};
// Result of resolving host credentials before the Android runtime starts.
// Credential ownership is retained only so the exact credential validated by
// AuthService can be moved into native startup without reopening its source.
// Retains the validated credential without reopening its source.
struct AuthRuntimeComposition {
AuthRuntimeStatus status = AuthRuntimeStatus::kUnavailable;
std::shared_ptr<jnivm::VM> jni_vm;
@@ -107,21 +99,14 @@ struct AuthRuntimeComposition {
explicit operator bool() const { return jni_vm != nullptr; }
};
// Resolves the saved Roblox session and constructs the production pseudo-VM.
// A VM is returned only for an authenticated session or when guest startup is
// explicitly enabled with MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP. An exact HTTP 401
// from a managed Mocktail file clears only the rejected .ROBLOSECURITY. A
// rejected default Sober credential is suppressed by a private fingerprint
// without modifying Sober's file. The guest policy then allows native sign-in
// to continue. Explicit overrides, unsafe recovery, other invalid credentials,
// and unavailable authentication still fail closed before native startup.
// A VM requires authentication or explicit guest mode. HTTP 401 clears only a
// managed credential; rejected Sober credentials are suppressed without
// changing Sober's file.
AuthRuntimeComposition ComposeAuthRuntime(const Environment& environment,
const RuntimePaths& paths,
services::AuthService& auth_service);
// Production overload retaining the HTTP transport for credentials delivered
// by native sign-in after startup. A persisted credential is validated before
// its account identity is promoted into the running pseudo-VM.
// Retains HTTP transport for native sign-in and validates persisted credentials.
AuthRuntimeComposition ComposeAuthRuntime(
const Environment& environment, const RuntimePaths& paths,
services::AuthService& auth_service,
+2 -9
View File
@@ -21,19 +21,12 @@
namespace mocktail {
namespace runtime {
// Applies Mocktail's mandatory privacy boundary to the flat Roblox
// client-settings override object. The policy disables native Crashpad, Java
// crash/ANR uploads, Lua Backtrace errors, and inferred-crash submission while
// preserving unrelated caller overrides. Unsafe values are replaced rather
// than treated as conflicts because crash upload is not a user-configurable
// runtime capability.
// Disables Roblox crash uploads while preserving unrelated overrides.
bool MergeCrashReportClientSettingsOverrides(std::string_view base_json,
std::string* merged_json,
std::string* error);
// Applies the same mandatory values to Roblox's separate AppBridge
// fastFlagsJson channel. Only FastVariable names are inserted here;
// Java-only client settings remain on the client-settings boundary above.
// AppBridge accepts only the FastVariable subset of this policy.
bool MergeCrashReportFastFlagsOverrides(std::string_view base_json,
std::string* merged_json,
std::string* error);
+4 -10
View File
@@ -28,9 +28,7 @@ enum class DeviceClass {
kConsole,
};
// One immutable profile keeps Roblox admission, pseudo-Android hardware
// identity, and built-in input capabilities consistent. The guest ABI and
// client-settings group remain Android/GoogleAndroidApp.
// Admission changes do not change the Android guest ABI.
struct DeviceProfile {
std::string name;
std::string cache_key;
@@ -51,18 +49,14 @@ struct DeviceProfile {
};
inline constexpr std::string_view kDefaultDeviceProfileName = "pc-windows-11";
// These identifiers select Roblox server-side admission only. They never
// replace the Android APK ABI or its GoogleAndroidApp client-settings group.
// Transport classifier only; the guest remains GoogleAndroidApp.
inline constexpr std::string_view kRobloxDesktopHttpUserAgent =
"Roblox/WinInet";
// The current Android-host playability endpoint recognizes this as console.
// PlayStation-labelled user agents currently fall back to desktop admission,
// so the PS5 marketing profile keeps this transport-only classifier.
// The playability endpoint recognizes this classifier as console.
inline constexpr std::string_view kRobloxConsoleAdmissionUserAgent =
"Roblox/XboxOne";
// Accepts canonical names and the convenient pc/mobile/console aliases.
// Returned profiles have static lifetime.
// Accepts canonical names and pc/mobile/console aliases; result is static.
const DeviceProfile* FindDeviceProfile(std::string_view name);
bool IsValidDeviceProfileValue(std::string_view value, std::size_t maximum);
std::string BuildCustomDeviceProfileCacheKey(const DeviceProfile& profile);
+66
View File
@@ -0,0 +1,66 @@
// 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
#ifndef MOCKTAIL_RUNTIME_DISCORD_RPC_H_
#define MOCKTAIL_RUNTIME_DISCORD_RPC_H_
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include "runtime/roblox_experience_presence.h"
#include "runtime/runtime_config.h"
namespace mocktail {
namespace runtime {
struct DiscordRpcActivity {
std::string details;
std::string state;
std::optional<int64_t> start_timestamp;
std::string large_image;
std::string large_text;
std::string button_label;
std::string button_url;
};
bool IsPublicDiscordJoin(const RobloxExperienceLaunchRequest& request);
std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request);
DiscordRpcActivity BuildDiscordRpcActivity(
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request, std::string place_name,
int64_t session_started_at, std::string place_icon_url = {});
// Maintains a direct, unauthenticated Discord Desktop RPC connection. The
// worker owns all network and IPC work; lifecycle notifications only replace
// a small in-memory snapshot and never block the Roblox main thread.
class DiscordRpcSession final {
public:
explicit DiscordRpcSession(DiscordRpcConfig config);
~DiscordRpcSession();
DiscordRpcSession(const DiscordRpcSession&) = delete;
DiscordRpcSession& operator=(const DiscordRpcSession&) = delete;
bool Start(std::string* detail = nullptr);
void Stop();
RobloxExperiencePresenceObserver observer();
private:
class Impl;
static void Notify(void* context, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request);
std::unique_ptr<Impl> impl_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_DISCORD_RPC_H_
+9 -25
View File
@@ -28,14 +28,10 @@ namespace runtime {
inline constexpr std::size_t kMaximumPendingExternalLaunches = 8;
struct ExternalLaunchBrokerOptions {
// Empty selects the current user's protected runtime endpoint. Tests and
// isolated launchers may provide an explicit socket under a private
// directory owned by the current user.
// Empty selects the current user's protected endpoint.
std::filesystem::path socket_path;
std::size_t maximum_pending_launches = kMaximumPendingExternalLaunches;
// The owner crosses configuration/bootstrap and a possible cgroup
// re-exec before it can safely ACK. A concurrent browser click waits through
// that bounded startup window instead of racing a not-yet-created socket.
// Covers bootstrap and a possible cgroup re-exec.
int forwarding_timeout_ms = 30000;
};
@@ -49,16 +45,11 @@ struct ExternalLaunchSink {
bool valid() const { return dispatch != nullptr; }
};
// Resolves the per-user, per-installation endpoint. Owner resolution may
// create only the private fallback directory; it never removes a stale
// socket. Socket cleanup belongs exclusively to StartOwnerAfterLockAcquired
// after the caller owns the matching Mocktail single-instance lock.
// Resolution never removes stale sockets; only a lock-owning broker may do so.
Status ResolveExternalLaunchSocketPath(bool owner,
std::filesystem::path* socket_path);
// Owns a bounded process-local launch queue and its same-user AF_UNIX endpoint.
// Wire requests contain only already-normalized ExperienceProtocol JSON; the
// raw browser URI and its gameinfo ticket never cross this boundary.
// The wire format contains normalized requests, never raw browser tickets.
class ExternalLaunchBroker final {
public:
~ExternalLaunchBroker();
@@ -68,25 +59,20 @@ class ExternalLaunchBroker final {
ExternalLaunchBroker(ExternalLaunchBroker&&) = delete;
ExternalLaunchBroker& operator=(ExternalLaunchBroker&&) = delete;
// The caller must hold the single-instance lock. Only this operation may
// remove a stale socket left by a crashed owner. An optional initial request
// is normalized and queued before the listener worker can accept later
// browser clicks, preserving click order without a startup race.
// Requires the single-instance lock. The initial request is queued before
// the listener starts, preserving click order.
static Status StartOwnerAfterLockAcquired(
ExternalLaunchBrokerOptions options,
std::shared_ptr<ExternalLaunchBroker>* broker,
const RobloxExperienceLaunchRequest* initial_request = nullptr);
// Forwards one normalized request and waits for an explicit owner ACK. The
// operation never starts a second runtime and never logs request contents.
// Waits for an owner ACK and never logs the request.
static Status ForwardToOwner(const ExternalLaunchBrokerOptions& options,
const RobloxExperienceLaunchRequest& request);
Status QueueInitialRequest(RobloxExperienceLaunchRequest request);
// Dispatches at most maximum_requests in FIFO order. A failed dispatch is
// returned to the front of the queue, so temporary downstream backpressure
// never drops a browser launch.
// A failed dispatch returns to the front of the FIFO queue.
Status Drain(const ExternalLaunchSink& sink, std::size_t maximum_requests);
Status Shutdown();
@@ -100,9 +86,7 @@ class ExternalLaunchBroker final {
std::unique_ptr<Impl> impl_;
};
// Process-global attachment used by the composition root and the dynamic
// ExperienceProtocol runtime. shared_ptr acquisition keeps the broker alive
// while an external request is being drained.
// shared_ptr acquisition keeps the active broker alive during a drain.
Status InstallActiveExternalLaunchBroker(
const std::shared_ptr<ExternalLaunchBroker>& broker);
void ClearActiveExternalLaunchBroker(const ExternalLaunchBroker* broker);
+3 -8
View File
@@ -22,19 +22,14 @@
namespace mocktail {
namespace runtime {
// Returns whether an interactive launch may present a desktop failure dialog.
// Readiness canaries and headless sessions remain strictly non-interactive.
// Canaries and headless sessions remain non-interactive.
bool FailureDialogsEnabled(const Environment& environment);
// Presents one synchronous GTK failure dialog. This is intended for startup
// failures that happen before the crash monitor can be armed.
bool ShowFailureDialog(const Environment& environment,
std::string_view message);
// Owns a small out-of-process GTK monitor. The helper is started before guest
// threads exist and receives an explicit success disposition. If Mocktail
// returns an error, calls _Exit(), or dies from a fatal signal, the helper
// remains able to present the last safe user-facing message.
// The helper starts before guest threads, so it can report errors and fatal
// exits even when the main process can no longer show a dialog.
class FailureDialogMonitor final {
public:
FailureDialogMonitor() = default;
+8 -13
View File
@@ -87,9 +87,9 @@ using ResumeGameFn = Status (*)(void* context, const GameSurface& surface);
using LeaveGameFn = Status (*)(void* context);
using ShutdownGameRuntimeFn = Status (*)(void* context);
// native boundary. All callbacks are synchronous and non-throwing. The
// coordinator serializes lifecycle invocation, matching MainGameActivity's
// single-thread executor without embedding JNI details in the policy layer.
// These callbacks form the native boundary. They are synchronous and
// non-throwing; the coordinator serializes lifecycle calls to match
// MainGameActivity's single-thread executor.
struct GameSessionCapabilities {
void* context = nullptr;
SetGameForegroundFn set_game_foreground = nullptr;
@@ -171,16 +171,11 @@ struct GameSessionSnapshot {
std::string failure_reason;
};
// Fail-closed policy for the MainGameActivity game-session branch.
//
// SurfaceCreated must prepare a generation before SurfaceChanged can supply its
// native window and dimensions. QueueJoinRequest and the prepared surface are
// independent initial prerequisites; whichever completes last starts the game
// with the current candidate surface. Initial start never calls ResumeGame.
// Once running, SurfaceChanged updates the active surface, SurfaceDestroyed
// pauses, and SurfaceCreated(new generation) plus SurfaceChanged resumes.
// Candidate surfaces become active only after the corresponding native call
// succeeds.
// SurfaceCreated must open a generation before its matching SurfaceChanged can
// supply a window and size. A join request and the first surface may arrive in
// either order; initial startup calls StartGame, never ResumeGame. Once running,
// new generations pause and resume the session. A candidate surface becomes
// active only after its native callback succeeds.
class GameSessionCoordinator final {
public:
explicit GameSessionCoordinator(GameSessionCapabilities capabilities,
@@ -22,8 +22,7 @@
namespace mocktail {
namespace runtime {
// adapter kept outside the research runtime. The opaque context must be
// the production jnivm::VM owned by the startup composition.
// The opaque context must be the jnivm::VM owned by startup composition.
bool SetJnivmPlatformWebCallbacks(
void* context, std::shared_ptr<void> callback_context,
void (*on_data_model_notification)(void*, JNIEnv*, jstring, jstring),
+2 -4
View File
@@ -53,10 +53,8 @@ struct CgroupMemoryLimitResult {
bool active() const { return status == CgroupMemoryLimitStatus::kActive; }
};
// Replaces the current process with the same command inside a transient
// systemd user scope when cgroup v2 delegation is available. A successful
// re-exec does not return. Systems without systemd continue with the watchdog
// fallback and receive kUnavailable.
// Compatibility hook for the former cgroup re-exec path. Automatic cgroup
// scope creation is disabled, so callers continue with the watchdog fallback.
CgroupMemoryLimitResult MaybeReexecWithCgroupMemoryLimit(
int argc, char* const argv[], std::uint64_t limit_bytes,
const std::vector<std::string>* reexec_arguments = nullptr);
+4 -14
View File
@@ -34,8 +34,7 @@ enum class PhysicsWorkerMode {
bool ParsePhysicsWorkerMode(std::string_view value, PhysicsWorkerMode* mode);
std::string_view PhysicsWorkerModeName(PhysicsWorkerMode mode);
// Makes every available physical core eligible for Roblox scheduler work.
// At least one scheduler worker remains on malformed topology input.
// Malformed topology still yields one worker.
int CalculateThroughputWorkerCount(int available_physical_cores);
struct PerformancePolicy {
@@ -61,25 +60,16 @@ PerformancePolicy ParsePerformancePolicy(
std::string_view memory_limit_mb = "0", std::string_view game_mode = "auto",
std::string_view physics_worker_mode = "auto");
// Counts unique physical cores available to the current process. Linux CPU
// affinity is respected; systems without readable topology fall back to the
// available logical CPU count.
// Respects CPU affinity and falls back to the available logical count.
int DetectAvailablePhysicalCoreCount();
// Merges the supported Roblox scheduler/rendering mode into the flat
// client-settings override object. Disabled policy leaves existing overrides
// intact. Conflicting explicit values fail closed instead of silently
// replacing the caller's settings.
// Disabled policy preserves overrides; conflicting values fail.
bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
std::string_view base_json,
std::string* merged_json,
std::string* error);
// Applies every runtime policy in a stable order before the resulting
// override object crosses the legacy startup boundary. The mandatory
// HttpClient compatibility policy is applied before the mandatory crash-report
// upload block. Neither policy can be overridden by a caller-provided client
// setting.
// Applies mandatory HTTP and crash policies after caller settings.
bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
const PerformancePolicy& performance,
std::string_view base_json,
@@ -143,11 +143,10 @@ struct RobloxBrowserServiceCallbackFactory {
}
};
// equivalent of the current APK's yg.c and gi.a BrowserService bridges.
// It binds the four exact MemStorage keys and owns every Callback and
// Connection until a disconnect-and-release shutdown has completed. Outgoing
// WebView events are queued without JNI and must be drained by the platform
// main thread.
// Host counterpart to the APK's yg.c and gi.a BrowserService bridges. It binds
// the four expected MemStorage keys. Shutdown disconnects before releasing its
// callbacks and connections. The platform main thread drains outgoing WebView
// events without holding JNI references.
class RobloxBrowserServiceBridge final {
public:
RobloxBrowserServiceBridge(JniEnvironmentProvider environment,
@@ -21,12 +21,14 @@
#include <deque>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include "mocktail/status.h"
#include "runtime/owned_pthread.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_experience_presence.h"
#include "runtime/roblox_fresh_game_launch_controller.h"
#include "runtime/roblox_web_view_bridge.h"
#include "runtime/webview_roblox_cookie.h"
@@ -120,7 +122,8 @@ class RobloxExperienceComposition final {
RobloxFreshLaunchPresentBoundary present_boundary,
RobloxGameSurfaceJniConfig surface_config = {},
const SecureRobloxCredential* initial_web_view_credential = nullptr,
RobloxExperienceSurfaceProvider surface_provider = {});
RobloxExperienceSurfaceProvider surface_provider = {},
RobloxExperiencePresenceObserver presence_observer = {});
~RobloxExperienceComposition();
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
@@ -185,6 +188,7 @@ class RobloxExperienceComposition final {
void* context, const RobloxBrowserServiceExecuteRequest& request);
static void WebSurfaceExited(void* context);
static void NotifyLateLuaAppDidReturn(void* context);
static void GamePresented(void* context, uint64_t frame_serial);
static void* RunLaunchWorker(void* context);
static Status RouteWebSurfaceEvent(WebSurfaceRoute route,
const WebViewHelperEvent& event,
@@ -205,6 +209,7 @@ class RobloxExperienceComposition final {
Status Dispatch(const RobloxExperienceLaunchRequest& request);
Status PromoteAuthenticatedSession();
Status RefreshLateSurface();
void PublishPresentedPresence(uint64_t frame_serial);
void RunActiveLaunch();
Status BuildPlatformGlobalObjects();
Status BuildLuaAppGlobalObjects();
@@ -218,6 +223,8 @@ class RobloxExperienceComposition final {
Status RestartLuaAppSurface(const GameSurface& surface);
Status ReleaseLuaAppGlobalObjects();
Status ReleaseGlobalObjects();
void NotifyPresence(RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request) const;
const JniEnvironmentProvider environment_;
const RobloxExperienceMessageBusSymbols message_bus_symbols_;
@@ -229,6 +236,7 @@ class RobloxExperienceComposition final {
const RobloxGameSurfaceJniConfig surface_config_;
const bool consumes_window_surface_events_;
const RobloxExperienceSurfaceProvider surface_provider_;
const RobloxExperiencePresenceObserver presence_observer_;
mutable std::mutex mutex_;
std::mutex surface_operation_mutex_;
@@ -253,6 +261,7 @@ class RobloxExperienceComposition final {
bool web_view_cookie_synchronized_ = false;
std::deque<RobloxExperienceLaunchRequest> pending_launch_requests_;
std::unique_ptr<LaunchTask> active_launch_;
std::optional<RobloxExperienceLaunchRequest> presence_request_;
std::string active_game_canonical_json_;
OwnedPthread launch_worker_;
uint64_t next_request_id_ = 1;
@@ -260,6 +269,7 @@ class RobloxExperienceComposition final {
bool subscribed_ = false;
bool launch_in_progress_ = false;
bool game_active_ = false;
bool playing_presence_published_ = false;
bool lua_app_return_pending_ = false;
bool controlled_switch_waiting_for_return_ = false;
bool lua_app_surface_recreation_pending_ = false;
@@ -0,0 +1,39 @@
// 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
#ifndef MOCKTAIL_RUNTIME_ROBLOX_EXPERIENCE_PRESENCE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_EXPERIENCE_PRESENCE_H_
#include "runtime/roblox_experience_launch_bridge.h"
namespace mocktail {
namespace runtime {
enum class RobloxExperiencePresencePhase {
kBrowsing,
kJoining,
kPlaying,
};
using NotifyRobloxExperiencePresenceFn =
void (*)(void* context, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request);
// Non-owning observer retained by RobloxExperienceComposition. Its owner must
// outlive the composition. Browsing notifications carry a null request.
struct RobloxExperiencePresenceObserver {
void* context = nullptr;
NotifyRobloxExperiencePresenceFn notify = nullptr;
bool valid() const { return notify != nullptr; }
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_EXPERIENCE_PRESENCE_H_
@@ -86,7 +86,8 @@ class RobloxFreshGameLaunchController final {
RobloxFreshGameLaunchController(
JniEnvironmentProvider environment, RobloxGameSessionSymbols symbols,
RobloxFreshLaunchPresentBoundary present_boundary,
RobloxGameSurfaceJniConfig surface_config = {});
RobloxGameSurfaceJniConfig surface_config = {},
RobloxGamePresentedObserver presented_observer = {});
~RobloxFreshGameLaunchController();
RobloxFreshGameLaunchController(const RobloxFreshGameLaunchController&) =
@@ -114,6 +115,7 @@ class RobloxFreshGameLaunchController final {
const RobloxGameSessionSymbols symbols_;
const RobloxFreshLaunchPresentBoundary present_boundary_;
const RobloxGameSurfaceJniConfig surface_config_;
const RobloxGamePresentedObserver presented_observer_;
mutable std::mutex mutex_;
std::unique_ptr<RobloxGameSessionRuntime> runtime_;
@@ -221,6 +221,16 @@ class RobloxGameSessionNativeAdapter final {
bool initialized_ = false;
};
using RobloxGamePresentedCallback = void (*)(void* context,
uint64_t frame_serial);
struct RobloxGamePresentedObserver {
void* context = nullptr;
RobloxGamePresentedCallback notify = nullptr;
bool valid() const { return notify != nullptr; }
};
// Small production owner that binds the JNI adapter to the policy coordinator.
// Initial startup always follows the APK sequence:
// principal -> join request -> SurfaceCreated(foreground) ->
@@ -229,7 +239,8 @@ class RobloxGameSessionRuntime final {
public:
RobloxGameSessionRuntime(JniEnvironmentProvider environment,
RobloxGameSessionSymbols symbols,
RobloxGameSurfaceJniConfig surface_config = {});
RobloxGameSurfaceJniConfig surface_config = {},
RobloxGamePresentedObserver presented_observer = {});
RobloxGameSessionRuntime(const RobloxGameSessionRuntime&) = delete;
RobloxGameSessionRuntime& operator=(const RobloxGameSessionRuntime&) = delete;
@@ -256,6 +267,7 @@ class RobloxGameSessionRuntime final {
private:
RobloxGameSessionNativeAdapter adapter_;
GameSessionCoordinator coordinator_;
const RobloxGamePresentedObserver presented_observer_;
// Makes the process-level pause/leave/destroy sequence atomic with respect
// to surface updates. Present evidence bypasses this mutex
// because StartGame can report it synchronously from the render callback.
+2 -1
View File
@@ -28,7 +28,8 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
// Converts the two Roblox website launch protocols into the owned
// ExperienceProtocol contract used by the supported game-session pipeline.
// Browser authentication tickets and tracker identifiers are // discarded: the runtime uses its already validated account identity.
// Authentication tickets and tracker IDs are discarded; the runtime already
// has a validated account identity.
Status ParseRobloxLaunchUri(std::string_view uri,
RobloxExperienceLaunchRequest* request);
@@ -22,8 +22,7 @@
namespace mocktail {
namespace runtime {
// host copy of the current APK's NativeTextBoxInfo Java value. The copy
// contains no text and remains valid after the JNI local reference is released.
// Host-owned NativeTextBoxInfo copy that outlives the JNI local reference.
struct RobloxNativeTextBoxInfoSnapshot {
float x = 0.0F;
float y = 0.0F;
+24 -1
View File
@@ -55,11 +55,30 @@ struct NetworkProxyConfig {
int port = 0;
};
struct DiscordRpcTextConfig {
std::string browsing = "Browsing experiences";
std::string joining = "Joining an experience";
std::string playing = "{place_name}";
std::string state = "Playing Roblox";
std::string unknown_place = "Unknown experience";
};
struct DiscordRpcConfig {
bool enabled = false;
bool show_place_name = true;
bool show_elapsed_time = true;
bool join_enabled = true;
bool public_servers_only = true;
std::string join_button_label = "Join Server";
std::string application_id;
DiscordRpcTextConfig text;
};
std::optional<NetworkProxyConfig> ParseNetworkProxyConfig(
std::string_view host, std::string_view port);
std::string BuildNetworkProxyUrl(const NetworkProxyConfig& proxy);
// Immutable, supported runtime options. Experimental MOCKTAIL_PATCH_* knobs
// Immutable, supported runtime options. Advanced MOCKTAIL_PATCH_* controls
// intentionally do not belong here; they remain isolated in the legacy path.
class RuntimeConfig {
public:
@@ -95,6 +114,8 @@ class RuntimeConfig {
const std::optional<NetworkProxyConfig>& network_proxy() const {
return network_proxy_;
}
const DiscordRpcConfig& discord_rpc() const { return discord_rpc_; }
bool discord_rpc_valid() const { return discord_rpc_valid_; }
// These legacy opt-ins create workers that do not own their VM/JNI state.
// Empty and "0" values are disabled; every other non-empty value is unsafe.
@@ -123,6 +144,8 @@ class RuntimeConfig {
std::string audio_output_device_ = "default";
bool audio_output_device_valid_ = true;
std::optional<NetworkProxyConfig> network_proxy_;
DiscordRpcConfig discord_rpc_;
bool discord_rpc_valid_ = true;
std::vector<std::string> unsafe_detached_thread_overrides_;
};
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 Mocktail Project Authors
// Licensed under the Apache License, Version 2.0.
#ifndef MOCKTAIL_RUNTIME_SESSION_LOG_H_
#define MOCKTAIL_RUNTIME_SESSION_LOG_H_
#include <chrono>
#include <filesystem>
#include <string>
#include <string_view>
#include "runtime/environment.h"
#include "runtime/runtime_paths.h"
namespace mocktail {
namespace runtime {
struct SessionBuildInformation {
std::string version;
std::string git_commit;
std::string git_dirty;
std::string build_type;
std::string compiler;
std::string target;
};
SessionBuildInformation CurrentSessionBuildInformation();
class SessionLog final {
public:
SessionLog() = default;
~SessionLog();
SessionLog(const SessionLog&) = delete;
SessionLog& operator=(const SessionLog&) = delete;
SessionLog(SessionLog&& other) noexcept;
SessionLog& operator=(SessionLog&& other) noexcept;
static SessionLog Start(const Environment& environment,
const RuntimePaths& paths,
std::chrono::system_clock::time_point started_at =
std::chrono::system_clock::now());
bool active() const { return active_; }
bool attempted() const { return attempted_; }
explicit operator bool() const { return active_; }
const std::filesystem::path& path() const { return path_; }
const std::filesystem::path& latest_path() const { return latest_path_; }
const std::string& error() const { return error_; }
const std::string& warning() const { return warning_; }
std::string Header(const Environment& environment, const RuntimePaths& paths,
std::string_view graphics_backend) const;
private:
void Stop();
bool attempted_ = false;
bool active_ = false;
int original_stdout_ = -1;
int original_stderr_ = -1;
int logger_process_ = -1;
std::chrono::system_clock::time_point started_at_{};
std::filesystem::path path_;
std::filesystem::path latest_path_;
std::string error_;
std::string warning_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_SESSION_LOG_H_
-1
View File
@@ -52,7 +52,6 @@ CandidateApprovalResult CreateCandidateApproval(
bool ValidateCandidateApproval(const std::filesystem::path& store_root,
std::string_view activation_json,
const PayloadIntegrityResult& payload,
const std::filesystem::path& runtime_binary,
CandidateApprovalResult* approval,
std::string* error);
+1 -1
View File
@@ -37,7 +37,7 @@ struct HostAbiDerivationResult {
}
};
// Derives an exact, experimental HostAbi profile by matching normalized x86-64
// 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
// before normal runtime activation.
+1 -1
View File
@@ -53,7 +53,7 @@ class PayloadStore final {
const std::array<std::filesystem::path, 2>& canary_logs);
PayloadStoreResult Import(const std::filesystem::path& prepared_payload);
PayloadStoreResult InspectCurrent() const;
PayloadStoreResult VerifyCurrent() const;
PayloadStoreResult VerifyCurrent();
PayloadStoreResult Rollback();
std::string StatusJson(std::string* error = nullptr) const;
+1
View File
@@ -22,6 +22,7 @@ namespace mocktail::update {
struct UpdateConfig {
bool automatic = true;
bool testing_latest_only = true;
bool launch_after_update = false;
std::string source = "apk-pure";
};
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228@gmail.com>
pkgname=mocktail
pkgver=0.1.0
pkgver=1.0.2
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
+4 -4
View File
@@ -1,6 +1,6 @@
pkgbase = mocktail-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 0.1.0
pkgver = 1.0.1
pkgrel = 1
url = https://github.com/komaruworld/mocktail
arch = x86_64
@@ -31,10 +31,10 @@ pkgbase = mocktail-bin
provides = mocktail
conflicts = mocktail
conflicts = mocktail-git
noextract = mocktail-0.1.0-1-x86_64.pkg.tar.zst
noextract = mocktail-1.0.1-1-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = mocktail-0.1.0-1-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/0.1.0/mocktail-0.1.0-1-x86_64.pkg.tar.zst
sha256sums = f8a44ca7bd35526035ce7d1902ff7a4b1981a7c36e54d82ce4fc655db6fdf0f3
source = mocktail-1.0.1-1-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.1/mocktail-1.0.1-1-x86_64.pkg.tar.zst
sha256sums = 63072fa49b3776cb164b71067696cd195e77470be7e0374444d4e8548d1de35c
pkgname = mocktail-bin
+2 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail-bin
pkgver=0.1.0
pkgver=1.0.1
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
@@ -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=('f8a44ca7bd35526035ce7d1902ff7a4b1981a7c36e54d82ce4fc655db6fdf0f3')
sha256sums=('63072fa49b3776cb164b71067696cd195e77470be7e0374444d4e8548d1de35c')
package() {
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
+2 -5
View File
@@ -1,6 +1,6 @@
pkgbase = mocktail-git
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 0.1.0.r1.g4e78c7d
pkgver = 1.0.1.r15.g0a03962
pkgrel = 1
url = https://github.com/komaruworld/mocktail
arch = x86_64
@@ -10,6 +10,7 @@ pkgbase = mocktail-git
makedepends = ninja
makedepends = nlohmann-json
makedepends = pkgconf
makedepends = vulkan-headers
depends = capstone
depends = curl
depends = fontconfig
@@ -38,10 +39,6 @@ pkgbase = mocktail-git
conflicts = mocktail-bin
options = !debug
source = mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main
source = libjnivm::git+https://github.com/ChristopherHX/libjnivm.git
source = vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git
sha256sums = SKIP
sha256sums = SKIP
sha256sums = SKIP
pkgname = mocktail-git
+6 -10
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail-git
pkgver=0.1.0.r1.g4e78c7d
pkgver=1.0.1.r15.g0a03962
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
@@ -37,6 +37,7 @@ makedepends=(
'ninja'
'nlohmann-json'
'pkgconf'
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
@@ -44,13 +45,9 @@ conflicts=('mocktail' 'mocktail-bin')
options=('!debug')
source=(
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
'libjnivm::git+https://github.com/ChristopherHX/libjnivm.git'
'vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git'
)
sha256sums=(
'SKIP'
'SKIP'
'SKIP'
)
pkgver() {
@@ -69,11 +66,9 @@ pkgver() {
prepare() {
cd mocktail
git submodule init
git config submodule.third_party/libjnivm.url "${srcdir}/libjnivm"
git config submodule.third_party/Vulkan-Headers.url \
"${srcdir}/vulkan-headers"
git -c protocol.file.allow=always submodule update
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
CMakeLists.txt
}
build() {
@@ -83,6 +78,7 @@ build() {
-DCMAKE_INSTALL_LIBDIR=lib \
-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/roblox_compatibility.json \
-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=/usr/share/mocktail/metadata/roblox_signing_certificates.json \
-DMOCKTAIL_ENABLE_UPSTREAM_JNIVM=OFF \
-DBUILD_TESTING=OFF
cmake --build build
}
+3 -6
View File
@@ -1,6 +1,6 @@
pkgbase = mocktail
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 0.1.0
pkgver = 1.0.2
pkgrel = 1
url = https://github.com/komaruworld/mocktail
arch = x86_64
@@ -10,6 +10,7 @@ pkgbase = mocktail
makedepends = ninja
makedepends = nlohmann-json
makedepends = pkgconf
makedepends = vulkan-headers
depends = capstone
depends = curl
depends = fontconfig
@@ -36,11 +37,7 @@ pkgbase = mocktail
conflicts = mocktail-bin
conflicts = mocktail-git
options = !debug
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=0.1.0
source = libjnivm::git+https://github.com/ChristopherHX/libjnivm.git
source = vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git
sha256sums = SKIP
sha256sums = SKIP
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.2
sha256sums = SKIP
pkgname = mocktail
+6 -10
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail
pkgver=0.1.0
pkgver=1.0.2
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
@@ -37,29 +37,24 @@ makedepends=(
'ninja'
'nlohmann-json'
'pkgconf'
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
conflicts=('mocktail-bin' 'mocktail-git')
options=('!debug')
source=(
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
'libjnivm::git+https://github.com/ChristopherHX/libjnivm.git'
'vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git'
)
sha256sums=(
'SKIP'
'SKIP'
'SKIP'
)
prepare() {
cd mocktail
git submodule init
git config submodule.third_party/libjnivm.url "${srcdir}/libjnivm"
git config submodule.third_party/Vulkan-Headers.url \
"${srcdir}/vulkan-headers"
git -c protocol.file.allow=always submodule update
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
CMakeLists.txt
}
build() {
@@ -69,6 +64,7 @@ build() {
-DCMAKE_INSTALL_LIBDIR=lib \
-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/roblox_compatibility.json \
-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=/usr/share/mocktail/metadata/roblox_signing_certificates.json \
-DMOCKTAIL_ENABLE_UPSTREAM_JNIVM=OFF \
-DBUILD_TESTING=OFF
cmake --build build
}
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
<title>Join a Roblox server with Mocktail</title>
<style>
:root { font: 17px/1.55 system-ui, sans-serif; color-scheme: dark; }
body { margin: 0; background: #0d1117; color: #e6edf3; }
main { max-width: 560px; margin: 15vh auto; padding: 32px; }
h1 { font-size: 2rem; }
p { color: #9da7b3; }
a { display: inline-block; padding: 12px 18px; border-radius: 9px;
background: #238636; color: white; text-decoration: none;
font-weight: 700; }
[hidden] { display: none; }
</style>
</head>
<body><main>
<h1>Join Server</h1>
<p id="status">Checking the Discord invitation…</p>
<a id="launch" hidden>Open Mocktail</a>
<noscript>This invitation requires JavaScript to open Mocktail.</noscript>
</main>
<script>
'use strict';
const status = document.querySelector('#status');
const launch = document.querySelector('#launch');
const query = new URLSearchParams(location.hash.slice(1));
const placeId = query.get('placeId') || '';
const gameInstanceId = query.get('gameInstanceId') || '';
const validPlace = /^[1-9][0-9]{0,18}$/.test(placeId);
const validInstance = gameInstanceId.length === 0 ||
(gameInstanceId.length <= 128 &&
!/[\u0000-\u001f\u007f]/.test(gameInstanceId));
if (!validPlace || !validInstance) {
status.textContent = 'This server invitation is invalid or incomplete.';
} else {
let target = 'roblox://experiences/start?placeId=' + placeId;
if (gameInstanceId.length > 0) {
target += '&gameInstanceId=' + encodeURIComponent(gameInstanceId);
}
launch.href = target;
launch.hidden = false;
status.textContent = gameInstanceId.length > 0
? 'Opening this public Roblox server in Mocktail…'
: 'Opening this Roblox experience in Mocktail…';
location.replace(target);
}
</script>
</body>
</html>
+14 -1
View File
@@ -12,7 +12,17 @@
"--socket=fallback-x11",
"--socket=pulseaudio",
"--device=dri",
"--talk-name=com.feralinteractive.GameMode"
"--talk-name=com.feralinteractive.GameMode",
"--filesystem=xdg-run/discord-ipc-0:rw",
"--filesystem=xdg-run/discord-ipc-1:rw",
"--filesystem=xdg-run/discord-ipc-2:rw",
"--filesystem=xdg-run/discord-ipc-3:rw",
"--filesystem=xdg-run/discord-ipc-4:rw",
"--filesystem=xdg-run/discord-ipc-5:rw",
"--filesystem=xdg-run/discord-ipc-6:rw",
"--filesystem=xdg-run/discord-ipc-7:rw",
"--filesystem=xdg-run/discord-ipc-8:rw",
"--filesystem=xdg-run/discord-ipc-9:rw"
],
"build-options": {
"strip": true,
@@ -166,6 +176,9 @@
"buildsystem": "cmake-ninja",
"config-opts": [
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_INSTALL_LIBDIR=lib",
"-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/app/share/mocktail/metadata/roblox_compatibility.json",
"-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=/app/share/mocktail/metadata/roblox_signing_certificates.json",
"-DBUILD_TESTING=OFF"
],
"sources": [
+43 -15
View File
@@ -4,46 +4,74 @@
<component type="desktop-application">
<id>space.bigrat.mocktail</id>
<name>Mocktail</name>
<summary>Experimental Roblox Android compatibility runtime for Linux</summary>
<summary>Play Roblox on Linux</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>Apache-2.0</project_license>
<developer id="space.bigrat">
<name>Mocktail Project Authors</name>
</developer>
<branding>
<color type="primary" scheme_preference="light">#CEB3EF</color>
<color type="primary" scheme_preference="dark">#2F2A6B</color>
</branding>
<url type="homepage">https://github.com/komaruworld/mocktail</url>
<description>
<p>
Mocktail is an experimental compatibility runtime for running the
Android x86-64 Roblox client on a native Linux desktop. It provides the
pseudo-JVM, Android ABI adapters, SDL window/input/audio integration and
Vulkan presentation needed by the client.
Sign in to Roblox, browse experiences, and play them on Linux.
</p>
<p>
The project is a prototype and is not affiliated with or endorsed by
Roblox Corporation. The verified Roblox payload is downloaded on first
launch and is not distributed with Mocktail.
Mocktail is an independent community project. It is not affiliated with
Roblox Corporation or VinegarHQ, and the Roblox client is not included.
</p>
</description>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/komaruworld/mocktail/0.1.0/docs/screenshots/flatpak-home.png</image>
<caption>Browse Roblox experiences in the desktop client</caption>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-home.png</image>
<caption>Browse Roblox experiences</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/0.1.0/docs/screenshots/flatpak-gameplay-tower.png</image>
<caption>Roblox gameplay running through Mocktail</caption>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-tower.png</image>
<caption>Play Tower of Hell on Linux</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/0.1.0/docs/screenshots/flatpak-gameplay-lobby.png</image>
<caption>A Roblox experience running in the Flatpak build</caption>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-lobby.png</image>
<caption>Play DOORS on Linux</caption>
</screenshot>
</screenshots>
<launchable type="desktop-id">space.bigrat.mocktail.desktop</launchable>
<content_rating type="oars-1.1"/>
<content_rating type="oars-1.1">
<content_attribute id="violence-cartoon">moderate</content_attribute>
<content_attribute id="violence-fantasy">moderate</content_attribute>
<content_attribute id="violence-realistic">moderate</content_attribute>
<content_attribute id="social-chat">moderate</content_attribute>
<content_attribute id="social-info">moderate</content_attribute>
<content_attribute id="social-audio">moderate</content_attribute>
<content_attribute id="social-contacts">intense</content_attribute>
<content_attribute id="money-purchasing">intense</content_attribute>
<content_attribute id="money-advertising">intense</content_attribute>
<content_attribute id="money-gambling">moderate</content_attribute>
</content_rating>
<provides>
<binary>mocktail</binary>
</provides>
<releases>
<release version="1.0.2" date="2026-08-14">
<description>
<ul>
<li>Fixed the “Roblox is out of date” bug</li>
<li>Improved logging</li>
<li>Fixed issues with Discord RPC where the game you were playing wasn’t displayed</li>
</ul>
</description>
</release>
<release version="1.0.1" date="2026-08-13">
<description>
<ul>
<li>Fix issues with GameMode</li>
<li>Add Discord RPC support</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-08-12"/>
</releases>
</component>
+1 -1
View File
@@ -59,7 +59,7 @@ VERSION_MARKER_PATTERN = re.compile(
class ProviderError(RuntimeError):
"""A safe, user-facing provider failure."""
"""Raised when a provider response cannot be downloaded or validated."""
def _has_version_record(metadata: bytes, version: str) -> bool:
+2 -2
View File
@@ -7,7 +7,7 @@
#
# http://www.apache.org/licenses/LICENSE-2.0
"""Download a provenance-pinned Uptodown XAPK bootstrap archive."""
"""Download a pinned Uptodown XAPK for Mocktail's first-run setup."""
from __future__ import annotations
@@ -47,7 +47,7 @@ DOWNLOAD_ORIGIN = "https://dw.uptodown.com/dwn/"
class ProviderError(RuntimeError):
"""A safe, user-facing provider failure."""
"""Raised when a provider response cannot be downloaded or validated."""
class DownloadPageParser(HTMLParser):
+32 -42
View File
@@ -9,7 +9,10 @@ readonly REPOSITORY="${1:?Flatpak repository path is required}"
readonly BUNDLE="${2:?Flatpak bundle path is required}"
readonly PUBLIC_KEY="${3:?GPG public key path is required}"
readonly OUTPUT="${4:?Pages output path is required}"
readonly BASE_URL="https://komaruworld.github.io/mocktail"
readonly NATIVE_REPOSITORIES="${5:?Native repositories path is required}"
readonly BASE_URL="https://mocktail.bigrat.space"
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly PROJECT_DIR="$(dirname -- "${SCRIPT_DIR}")"
[[ -d "${REPOSITORY}" && ! -L "${REPOSITORY}" ]] || {
printf 'Flatpak repository is missing or unsafe: %s\n' "${REPOSITORY}" >&2
@@ -23,6 +26,13 @@ readonly BASE_URL="https://komaruworld.github.io/mocktail"
printf 'Flatpak public key is missing or unsafe: %s\n' "${PUBLIC_KEY}" >&2
exit 1
}
[[ -d "${NATIVE_REPOSITORIES}/apt" &&
-d "${NATIVE_REPOSITORIES}/rpm" &&
-d "${NATIVE_REPOSITORIES}/downloads" &&
-s "${NATIVE_REPOSITORIES}/mocktail-packages.gpg" ]] || {
printf 'Native repositories are missing: %s\n' "${NATIVE_REPOSITORIES}" >&2
exit 1
}
[[ ! -e "${OUTPUT}" ]] || {
printf 'Pages output already exists: %s\n' "${OUTPUT}" >&2
exit 1
@@ -33,67 +43,47 @@ cp -a -- "${REPOSITORY}" "${OUTPUT}/repo"
install -m 0644 -- "${BUNDLE}" "${OUTPUT}/Mocktail-x86_64.flatpak"
install -m 0644 -- "${PUBLIC_KEY}" "${OUTPUT}/mocktail-flatpak.gpg"
install -m 0644 -- \
"$(dirname -- "${BASH_SOURCE[0]}")/../packaging/space.bigrat.mocktail.svg" \
"${PROJECT_DIR}/packaging/space.bigrat.mocktail.svg" \
"${OUTPUT}/mocktail.svg"
install -m 0644 -- \
"${PROJECT_DIR}/packaging/discord-join.html" \
"${OUTPUT}/join.html"
install -m 0644 -- "${PROJECT_DIR}/site/index.html" "${OUTPUT}/index.html"
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
install -d -- "${OUTPUT}/packaging"
install -m 0644 -- \
"${PROJECT_DIR}/packaging/space.bigrat.mocktail.svg" \
"${OUTPUT}/packaging/space.bigrat.mocktail.svg"
cp -a -- "${NATIVE_REPOSITORIES}/apt" "${OUTPUT}/apt"
cp -a -- "${NATIVE_REPOSITORIES}/rpm" "${OUTPUT}/rpm"
cp -a -- "${NATIVE_REPOSITORIES}/downloads" "${OUTPUT}/downloads"
install -m 0644 -- \
"${NATIVE_REPOSITORIES}/mocktail-packages.gpg" \
"${OUTPUT}/mocktail-packages.gpg"
readonly GPG_KEY="$(base64 --wrap=0 "${PUBLIC_KEY}")"
cat >"${OUTPUT}/mocktail.flatpakrepo" <<EOF
[Flatpak Repo]
Title=Mocktail
Title=Mocktail Nightly
Url=${BASE_URL}/repo/
Homepage=https://github.com/komaruworld/mocktail
Comment=Development builds of Mocktail
Description=Signed x86_64 Flatpak builds published from Mocktail main
Comment=Nightly builds of Mocktail
Description=Signed x86_64 nightly builds published from the latest Mocktail main branch
Icon=${BASE_URL}/mocktail.svg
GPGKey=${GPG_KEY}
EOF
cat >"${OUTPUT}/mocktail.flatpakref" <<EOF
[Flatpak Ref]
Title=Mocktail
Title=Mocktail Nightly
Name=space.bigrat.mocktail
Branch=stable
Url=${BASE_URL}/repo/
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
Homepage=https://github.com/komaruworld/mocktail
Comment=Experimental Linux compatibility runtime
Comment=Nightly build from the latest main branch
Icon=${BASE_URL}/mocktail.svg
GPGKey=${GPG_KEY}
IsRuntime=false
EOF
cat >"${OUTPUT}/index.html" <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Mocktail for Linux</title>
<style>
:root { font: 17px/1.55 system-ui, sans-serif; color-scheme: dark; }
body { margin: 0; background: #0d1117; color: #e6edf3; }
main { max-width: 680px; margin: 12vh auto; padding: 32px; }
img { width: 88px; height: 88px; }
h1 { font-size: clamp(2.5rem, 8vw, 4.5rem); margin: .2rem 0; }
p { color: #9da7b3; }
a.button { display: inline-block; margin: 16px 8px 16px 0; padding: 12px 18px;
border-radius: 9px; background: #238636; color: white; text-decoration: none;
font-weight: 700; }
a.secondary { background: #21262d; }
code { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
display: block; padding: 14px; overflow-wrap: anywhere; }
</style>
</head>
<body><main>
<img src="mocktail.svg" alt="Mocktail logo">
<h1>Mocktail</h1>
<p>Signed development Flatpak builds for Linux x86_64.</p>
<a class="button" href="mocktail.flatpakref">Install with Flatpak</a>
<a class="button secondary" href="https://github.com/komaruworld/mocktail">Source</a>
<code>flatpak install --user https://komaruworld.github.io/mocktail/mocktail.flatpakref</code>
<p>Mocktail is experimental and is not affiliated with Roblox Corporation.</p>
</main></body>
</html>
EOF
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
set -Eeuo pipefail
umask 022
readonly STABLE_DEB="${1:?Stable DEB path is required}"
readonly NIGHTLY_DEB="${2:?Nightly DEB path is required}"
readonly STABLE_RPM="${3:?Stable RPM path is required}"
readonly NIGHTLY_RPM="${4:?Nightly RPM path is required}"
readonly PUBLIC_KEY="${5:?GPG public key path is required}"
readonly GPG_KEY_ID="${6:?GPG key ID is required}"
readonly GPG_HOME="${7:?GPG home path is required}"
readonly OUTPUT="${8:?Repository output path is required}"
readonly BASE_URL="https://mocktail.bigrat.space"
for command in apt-ftparchive createrepo_c dpkg-deb gpg gzip rpm rpmsign; do
command -v "${command}" >/dev/null || {
printf 'Required command is missing: %s\n' "${command}" >&2
exit 1
}
done
for package in "${STABLE_DEB}" "${NIGHTLY_DEB}" \
"${STABLE_RPM}" "${NIGHTLY_RPM}"; do
[[ -s "${package}" && ! -L "${package}" ]] || {
printf 'Package is missing or unsafe: %s\n' "${package}" >&2
exit 1
}
done
[[ -s "${PUBLIC_KEY}" && ! -L "${PUBLIC_KEY}" ]] || {
printf 'GPG public key is missing or unsafe: %s\n' "${PUBLIC_KEY}" >&2
exit 1
}
[[ -d "${GPG_HOME}" && ! -L "${GPG_HOME}" ]] || {
printf 'GPG home is missing or unsafe: %s\n' "${GPG_HOME}" >&2
exit 1
}
[[ ! -e "${OUTPUT}" ]] || {
printf 'Repository output already exists: %s\n' "${OUTPUT}" >&2
exit 1
}
[[ "$(dpkg-deb --field "${STABLE_DEB}" Package)" == mocktail ]]
[[ "$(dpkg-deb --field "${NIGHTLY_DEB}" Package)" == mocktail-nightly ]]
[[ "$(rpm --query --package --queryformat '%{NAME}' "${STABLE_RPM}")" == mocktail ]]
[[ "$(rpm --query --package --queryformat '%{NAME}' "${NIGHTLY_RPM}")" == mocktail-nightly ]]
readonly APT_ROOT="${OUTPUT}/apt"
readonly APT_POOL="${APT_ROOT}/pool/main/m/mocktail"
readonly APT_DIST="${APT_ROOT}/dists/mocktail"
readonly RPM_ROOT="${OUTPUT}/rpm/x86_64"
readonly DOWNLOADS="${OUTPUT}/downloads"
install -d -- \
"${APT_POOL}" "${APT_DIST}/main/binary-amd64" "${RPM_ROOT}" "${DOWNLOADS}"
install -m 0644 -- "${STABLE_DEB}" "${APT_POOL}/$(basename -- "${STABLE_DEB}")"
install -m 0644 -- "${NIGHTLY_DEB}" "${APT_POOL}/$(basename -- "${NIGHTLY_DEB}")"
install -m 0644 -- "${STABLE_RPM}" "${RPM_ROOT}/$(basename -- "${STABLE_RPM}")"
install -m 0644 -- "${NIGHTLY_RPM}" "${RPM_ROOT}/$(basename -- "${NIGHTLY_RPM}")"
install -m 0644 -- "${STABLE_DEB}" "${DOWNLOADS}/mocktail.deb"
install -m 0644 -- "${STABLE_RPM}" "${DOWNLOADS}/mocktail.rpm"
install -m 0644 -- "${PUBLIC_KEY}" "${OUTPUT}/mocktail-packages.gpg"
(
cd -- "${APT_ROOT}"
apt-ftparchive packages pool/main >dists/mocktail/main/binary-amd64/Packages
gzip -9n -c dists/mocktail/main/binary-amd64/Packages \
>dists/mocktail/main/binary-amd64/Packages.gz
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=Mocktail \
-o APT::FTPArchive::Release::Label=Mocktail \
-o APT::FTPArchive::Release::Suite=mocktail \
-o APT::FTPArchive::Release::Codename=mocktail \
-o APT::FTPArchive::Release::Architectures=amd64 \
-o APT::FTPArchive::Release::Components=main \
release dists/mocktail >dists/mocktail/Release
)
gpg --batch --yes --homedir "${GPG_HOME}" --local-user "${GPG_KEY_ID}" \
--digest-algo SHA256 --clearsign \
--output "${APT_DIST}/InRelease" "${APT_DIST}/Release"
gpg --batch --yes --homedir "${GPG_HOME}" --local-user "${GPG_KEY_ID}" \
--digest-algo SHA256 --armor --detach-sign \
--output "${APT_DIST}/Release.gpg" "${APT_DIST}/Release"
for package in "${RPM_ROOT}"/*.rpm; do
GNUPGHOME="${GPG_HOME}" rpmsign --addsign \
--define "_gpg_name ${GPG_KEY_ID}" \
--define "_gpg_path ${GPG_HOME}" \
"${package}"
done
createrepo_c "${RPM_ROOT}"
gpg --batch --yes --homedir "${GPG_HOME}" --local-user "${GPG_KEY_ID}" \
--digest-algo SHA256 --armor --detach-sign \
--output "${RPM_ROOT}/repodata/repomd.xml.asc" \
"${RPM_ROOT}/repodata/repomd.xml"
cat >"${OUTPUT}/rpm/mocktail.repo" <<EOF
[mocktail]
name=Mocktail
baseurl=${BASE_URL}/rpm/\$basearch/
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=${BASE_URL}/mocktail-packages.gpg
EOF
+33 -1
View File
@@ -86,6 +86,7 @@ LAUNCH="${MOCKTAIL_UPDATE_LAUNCH:-false}"
SKIP_BUILD=false
SCHEDULED=false
STARTUP_PREFLIGHT=false
TESTING_LATEST_ONLY=true
UPDATE_CONFIG_JSON="{}"
STAGED_PAYLOAD_ID=""
DOWNLOAD_STAGING_ROOT="${CACHE_ROOT}/downloads/apk-staging"
@@ -113,7 +114,8 @@ Options:
--skip-build Reuse the existing Release binary for the canary.
--scheduled Run only when updates.automatic is true in YAML.
--startup-preflight Validate a runnable local payload without checking for
remote updates; provision one when none is available.
remote updates, unless updates.testing_latest_only is
enabled; provision one when none is available.
-h, --help Show this help.
EOF
}
@@ -188,6 +190,8 @@ ParseArguments() {
LoadYamlDefaults() {
UPDATE_CONFIG_JSON="$(python3 "${SCRIPT_DIR}/read_update_config.py" "${CONFIG_FILE}")"
[[ -n "${SOURCE}" ]] || SOURCE="$(jq -r '.source // "apk-pure"' <<<"${UPDATE_CONFIG_JSON}")"
TESTING_LATEST_ONLY="$(jq -r '.testing_latest_only // true' \
<<<"${UPDATE_CONFIG_JSON}")"
if [[ "${LAUNCH}" != true &&
"$(jq -r '.launch_after_update // false' <<<"${UPDATE_CONFIG_JSON}")" == true ]]; then
LAUNCH=true
@@ -562,6 +566,9 @@ SkipDownloadWhenCurrent() {
Log "latest-version API check failed; preserving the runnable local payload"
return 0
fi
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
Die "updates.testing_latest_only requires available provider latest metadata"
fi
Log "latest-version API check failed and no runnable local payload exists; using the compatibility bootstrap profile"
SelectSupportedBootstrapFallback
return 1
@@ -572,6 +579,9 @@ SkipDownloadWhenCurrent() {
Log "latest-version API returned invalid metadata; preserving the runnable local payload"
return 0
fi
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
Die "updates.testing_latest_only requires valid provider latest metadata"
fi
Log "provider returned invalid version metadata and no runnable local payload exists; using the compatibility bootstrap profile"
SelectSupportedBootstrapFallback
return 1
@@ -605,6 +615,11 @@ SkipRemoteUpdateForStartup() {
Die "current payload is not runnable by this Mocktail runtime and rollback failed"
fi
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
Log "testing latest-only track enabled; checking provider latest during startup"
return 1
fi
local current_payload_id
current_payload_id="$(VerifiedCurrentPayloadId 2>/dev/null || true)"
if [[ "${current_payload_id}" =~ ^[0-9]+-[0-9a-f]{40}$ ]]; then
@@ -1551,6 +1566,9 @@ Main() {
LoadYamlDefaults
ParseArguments "$@"
[[ "${SOURCE}" == apk-pure ]] || Die "update source must be apk-pure"
if [[ "${TESTING_LATEST_ONLY}" == true && -n "${VERSION}" ]]; then
Die "--version cannot be combined with updates.testing_latest_only"
fi
if [[ "${SCHEDULED}" == true &&
"$(jq -r 'if has("automatic") then .automatic else true end' \
<<<"${UPDATE_CONFIG_JSON}")" != true ]]; then
@@ -1585,6 +1603,10 @@ Main() {
PreserveCurrentAfterUpdateFailure "latest payload acquisition failed" \
"${pre_stage_payload_id}" &&
return 0
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
Log "latest payload acquisition failed; testing latest-only track forbids a supported fallback"
return "${stage_status}"
fi
local bootstrap_ceiling=""
if [[ "${BOOTSTRAP_SELECTED_VERSION_CODE}" =~ ^[0-9]+$ ]]; then
(( 10#${BOOTSTRAP_SELECTED_VERSION_CODE} > 1 )) ||
@@ -1618,6 +1640,16 @@ Main() {
fi
return 0
fi
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
if [[ "${had_current}" == true ]]; then
PreserveCurrentAfterUpdateFailure \
"latest candidate ${latest_payload_id} failed probation" \
"${LAST_ATTEMPT_BASELINE_PAYLOAD_ID}"
return 0
fi
Log "latest candidate ${latest_payload_id} failed probation; testing latest-only track forbids a supported fallback"
return "${attempt_status}"
fi
if [[ "${latest_needs_reference}" != true && "${had_current}" == true ]]; then
PreserveCurrentAfterUpdateFailure \
"latest candidate ${latest_payload_id} failed probation" \
-48
View File
@@ -6,47 +6,9 @@
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# scripts/build.sh — Mocktail one-shot native build script.
#
# Usage:
# ./scripts/build.sh [OPTIONS]
#
# Options:
# --apk <path> Explicitly extract a local Roblox x86_64 APK.
# --apk-url <url> Explicitly download and extract an APK URL.
# --skip-apk Compatibility alias for the default managed-payload mode.
# --build-dir <path> CMake build directory. Relative paths use project root.
# Default: build.
# --cmake-toolchain <file>
# Explicit CMake toolchain file.
# --cmake-sysroot <dir>
# Explicit compiler/CMake sysroot.
# --build-type <type> CMake build type: Debug | Release | RelWithDebInfo.
# Default: Release.
# --jobs <n> Parallel build jobs. Default: nproc.
# --run-tests Run CTest after build.
# --clean Remove the build/ directory before configuring.
# --help Show this help and exit.
#
# Examples:
# # Build with a local APK file:
# ./scripts/build.sh --apk ~/Downloads/roblox-x86_64.apk
#
# # Build with a direct download URL:
# ./scripts/build.sh --apk-url "https://example.com/roblox-x86_64.apk"
#
# # Rebuild the C++ project; the first launch obtains the managed payload:
# ./scripts/build.sh --skip-apk --build-type Debug --run-tests
#
# With no APK option, Mocktail builds only the native runtime. Its first normal
# launch downloads, validates, canaries, and activates a supported x86_64
# payload in the user's XDG data directory. ROBLOX_LIB_PATH remains an explicit
# research override and disables APK extraction when already set.
set -euo pipefail
# Colour helpers
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -91,7 +53,6 @@ Examples:
EOF
}
# Default configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUILD_DIR="${PROJECT_ROOT}/build"
@@ -107,7 +68,6 @@ CLEAN=false
CMAKE_TOOLCHAIN_FILE=""
CMAKE_SYSROOT=""
# Argument parsing
while [[ $# -gt 0 ]]; do
case "$1" in
--apk)
@@ -180,7 +140,6 @@ info "Mocktail build: ${BUILD_TYPE}, -j${JOBS}"
info "Project root: ${PROJECT_ROOT}"
info "Build directory: ${BUILD_DIR}"
# Step 1 — Check required tools
step "1/5 checking dependencies"
check_tool() {
@@ -208,7 +167,6 @@ if [[ "${EXTRACT_APK}" == true && -n "${APK_URL}" ]] &&
check_tool wget "sudo apt install wget"
fi
# Step 2 — Download APK (if URL provided)
if [[ "${EXTRACT_APK}" == true && -n "${APK_URL}" ]]; then
step "2/5 downloading Roblox APK"
APK_PATH="${PROJECT_ROOT}/rbx_bin/roblox-x86_64.apk"
@@ -223,7 +181,6 @@ if [[ "${EXTRACT_APK}" == true && -n "${APK_URL}" ]]; then
success "APK downloaded."
fi
# Step 3 — Extract libroblox.so from APK
LIBROBLOX_DST="${RBX_BIN_DIR}/libroblox.so"
if [[ -n "${ROBLOX_LIB_PATH:-}" ]]; then
@@ -251,7 +208,6 @@ else
success "No APK or libroblox.so is required at build time."
fi
# Step 4 — Init git submodules
step "4/5 initialising git submodules"
cd "${PROJECT_ROOT}"
if [[ -f ".gitmodules" ]]; then
@@ -262,7 +218,6 @@ else
info "To add submodules run: ./scripts/add_submodules.sh"
fi
# Step 5 — CMake configure
step "5/5 configuring CMake"
if [[ "${CLEAN}" == true && -d "${BUILD_DIR}" ]]; then
@@ -293,21 +248,18 @@ cmake "${cmake_arguments[@]}"
success "CMake configuration complete."
# Step 6 — Build
step "building Mocktail"
cmake --build "${BUILD_DIR}" -j"${JOBS}"
success "Build complete → ${BUILD_DIR}/mocktail"
# Step 7 — Run tests (optional)
if [[ "${RUN_TESTS}" == true ]]; then
step "running unit tests"
ctest --test-dir "${BUILD_DIR}" --output-on-failure -j"${JOBS}"
success "All tests passed."
fi
# Done
echo
success "Build successful."
echo "Run:"
+117 -28
View File
@@ -13,12 +13,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Derive a fail-closed HostAbi profile from one exact approved payload.
"""Build a HostAbi profile for one approved payload without running it.
The analyzer never executes guest code and never marks a payload supported.
It accepts only an exact reference sidecar bound to the reference ELF digest,
matches native boundaries structurally, and emits an experimental manifest for
an isolated updater probation run.
The reference sidecar must match the reference ELF digest. The resulting
manifest is meant for an updater probation run; it does not mark the payload as
supported.
"""
from __future__ import annotations
@@ -40,7 +39,7 @@ from typing import Any, Iterator, Sequence
try:
import capstone
from capstone import x86 as capstone_x86
except ImportError: # The CLI turns this into one explicit fail-closed error.
except ImportError:
capstone = None
capstone_x86 = None
@@ -107,7 +106,7 @@ PRIMARY_TOOLCHAIN_MARKERS = (
class AnalyzerError(RuntimeError):
"""One deterministic fail-closed analysis error."""
"""Raised when HostAbi analysis rejects an input."""
@dataclass(frozen=True)
@@ -1089,9 +1088,13 @@ def validate_semantic_match(
raise AnalyzerError("registry initializer object size is inconsistent")
def find_signature_match(
reference: ElfImage, candidate: ElfImage, spec: SignatureSpec
) -> SignatureMatch:
def find_signature_matches(
reference: ElfImage,
candidate: ElfImage,
spec: SignatureSpec,
*,
allow_multiple_candidates: bool = False,
) -> tuple[SignatureMatch, ...]:
reference_instructions = disassemble(
reference, spec.reference_rva, spec.instruction_count
)
@@ -1132,18 +1135,59 @@ def find_signature_match(
):
matches.append(signature_offset)
search_offset = anchor_position + 1
if not matches:
raise AnalyzerError(
f"signature {spec.name} matched 0 candidate locations"
)
if not allow_multiple_candidates and len(matches) != 1:
raise AnalyzerError(
f"signature {spec.name} matched {len(matches)} candidate locations"
)
semantic_matches = []
semantic_errors = []
for match in matches:
match_rva = text.address + match - text.offset
candidate_instructions = disassemble(
candidate, match_rva, spec.instruction_count
)
try:
validate_semantic_match(
reference_instructions,
candidate_instructions,
spec.allow_registry_object_size_change,
)
except AnalyzerError as error:
semantic_errors.append(error)
continue
semantic_matches.append(
SignatureMatch(
match_rva, reference_instructions, candidate_instructions
)
)
if not semantic_matches and len(matches) == 1 and semantic_errors:
raise semantic_errors[0]
return tuple(semantic_matches)
def find_signature_match(
reference: ElfImage, candidate: ElfImage, spec: SignatureSpec
) -> SignatureMatch:
matches = find_signature_matches(reference, candidate, spec)
if len(matches) != 1:
raise AnalyzerError(
f"signature {spec.name} matched {len(matches)} candidate locations"
)
match_rva = text.address + matches[0] - text.offset
candidate_instructions = disassemble(candidate, match_rva, spec.instruction_count)
validate_semantic_match(
reference_instructions,
candidate_instructions,
spec.allow_registry_object_size_change,
)
return SignatureMatch(match_rva, reference_instructions, candidate_instructions)
return matches[0]
def direct_call_target(instruction: Any, description: str) -> int:
if (
instruction.mnemonic != "call"
or len(instruction.operands) != 1
or instruction.operands[0].type != capstone_x86.X86_OP_IMM
):
raise AnalyzerError(f"{description} is not one direct call")
return instruction.operands[0].imm
def rip_targets(instruction: Any) -> tuple[int, ...]:
@@ -1257,7 +1301,13 @@ def create_signature_specs(
24,
),
SignatureSpec(
"usable-size", parse_rva(bridges["usable-size"]["rva"], "usable size"), 24
# This bridge is an 18-instruction leaf. Keeping the signature
# bounded to the leaf avoids coupling it to padding or the next
# function, which changed from executable code to INT3 padding in
# Roblox versionCode 2904.
"usable-size",
parse_rva(bridges["usable-size"]["rva"], "usable size"),
18,
),
SignatureSpec(
"reallocate", parse_rva(bridges["reallocate"]["rva"], "reallocate"), 24
@@ -1278,8 +1328,12 @@ def create_signature_specs(
),
SignatureSpec(
"allocator-thread-initializer",
parse_rva(data_seeds["allocator_thread_initializer"], "thread initializer"),
24,
parse_rva(
data_seeds["allocator_thread_initializer"], "thread initializer"
),
# The wrapper ends at instruction 19. Later instructions belong
# to an unrelated adjacent initializer.
19,
),
SignatureSpec(
"registry-initializer",
@@ -1363,10 +1417,47 @@ def derive_profile(
reference_profile = reference_sidecar["profile"]
anchors = reference_sidecar["derivation_anchors"]
matches = {
spec.name: find_signature_match(reference, candidate, spec)
for spec in create_signature_specs(reference_profile, anchors)
}
matches = {}
allocate_matches = ()
for spec in create_signature_specs(reference_profile, anchors):
if spec.name == "allocate":
allocate_matches = find_signature_matches(
reference, candidate, spec, allow_multiple_candidates=True
)
else:
matches[spec.name] = find_signature_match(reference, candidate, spec)
# The allocate signature alone is intentionally ambiguous in 2904. Resolve
# it through an independent invariant: the registry initializer directly
# calls the allocator wrapper that Mocktail must publish in the profile.
registry_match = matches["registry-initializer"]
reference_allocate = parse_rva(
bridge_map(reference_profile)["allocate"]["rva"],
"reference allocate",
)
if (
direct_call_target(
registry_match.reference_instructions[9],
"reference registry allocator call",
)
!= reference_allocate
):
raise AnalyzerError(
"reference registry initializer does not call its allocator bridge"
)
candidate_allocate = direct_call_target(
registry_match.candidate_instructions[9],
"candidate registry allocator call",
)
selected_allocate = tuple(
match for match in allocate_matches if match.rva == candidate_allocate
)
if len(selected_allocate) != 1:
raise AnalyzerError(
"signature allocate candidates do not identify exactly one "
"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"]:
@@ -1636,9 +1727,7 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Derive an exact experimental Roblox HostAbi sidecar for isolated probation"
)
description="Derive a probationary Roblox HostAbi sidecar"
)
parser.add_argument("--reference-lib", required=True, metavar="REF")
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
# Licensed under the Apache License, Version 2.0.
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
CONFIG_ROOT="${XDG_CONFIG_HOME:-${HOME}/.config}/mocktail"
UNIT_ROOT="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user"
CANARY_BINARY="${MOCKTAIL_UPDATE_CANARY_BIN:-}"
UPDATE_HELPER="${MOCKTAIL_UPDATE_HELPER:-}"
if [[ -z "${CANARY_BINARY}" ]]; then
for candidate in "${PROJECT_ROOT}/build/mocktail" \
/usr/lib/mocktail/mocktail; do
if [[ -f "${candidate}" && ! -L "${candidate}" && -x "${candidate}" ]]; then
CANARY_BINARY="${candidate}"
break
fi
done
fi
if [[ -z "${UPDATE_HELPER}" ]]; then
for candidate in "${PROJECT_ROOT}/build/mocktail_updater" \
/usr/lib/mocktail/mocktail_updater; do
if [[ -f "${candidate}" && ! -L "${candidate}" && -x "${candidate}" ]]; then
UPDATE_HELPER="${candidate}"
break
fi
done
fi
[[ "${CANARY_BINARY}" == /* && -f "${CANARY_BINARY}" &&
! -L "${CANARY_BINARY}" && -x "${CANARY_BINARY}" ]] || {
echo "build or install Mocktail before enabling its update timer" >&2
exit 1
}
[[ "${UPDATE_HELPER}" == /* && -f "${UPDATE_HELPER}" &&
! -L "${UPDATE_HELPER}" && -x "${UPDATE_HELPER}" ]] || {
echo "build or install the native Mocktail updater before enabling its timer" >&2
exit 1
}
[[ "${PROJECT_ROOT}" != *$'\n'* && "${PROJECT_ROOT}" != *'%'* ]] || {
echo "unsupported project path for systemd environment file" >&2
exit 1
}
mkdir -p "${CONFIG_ROOT}" "${UNIT_ROOT}"
install -m 0644 "${PROJECT_ROOT}/config/systemd/mocktail-update.service" \
"${UNIT_ROOT}/mocktail-update.service"
install -m 0644 "${PROJECT_ROOT}/config/systemd/mocktail-update.timer" \
"${UNIT_ROOT}/mocktail-update.timer"
if [[ ! -e "${CONFIG_ROOT}/config.yaml" ]]; then
install -m 0600 "${PROJECT_ROOT}/config/mocktail.example.yaml" \
"${CONFIG_ROOT}/config.yaml"
fi
temporary="$(mktemp "${CONFIG_ROOT}/.updater.env.XXXXXX")"
trap 'rm -f -- "${temporary}"' EXIT
printf 'MOCKTAIL_UPDATE_HELPER=%q\n' "${UPDATE_HELPER}" > "${temporary}"
printf 'MOCKTAIL_UPDATE_CANARY_BIN=%q\n' "${CANARY_BINARY}" >> "${temporary}"
chmod 0600 "${temporary}"
mv -f -- "${temporary}" "${CONFIG_ROOT}/updater.env"
trap - EXIT
systemctl --user daemon-reload
systemctl --user enable --now mocktail-update.timer
systemctl --user list-timers mocktail-update.timer --no-pager
+2 -1
View File
@@ -105,13 +105,14 @@ def main() -> None:
fail("updates must be a mapping")
allowed = {
"automatic",
"testing_latest_only",
"source",
"launch_after_update",
}
unknown = set(updates) - allowed
if unknown:
fail(f"unknown updates key: {sorted(unknown)[0]}")
for key in ("automatic", "launch_after_update"):
for key in ("automatic", "testing_latest_only", "launch_after_update"):
if key in updates and not isinstance(updates[key], bool):
fail(f"updates.{key} must be true or false")
if "source" in updates and updates["source"] not in SUPPORTED_SOURCES:
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Progressive real-startup harness for Mocktail.
# Tiers A, B, and LEGACY are research experiments. Tier C verifies the
# Tiers A, B, and LEGACY are diagnostic modes. Tier C verifies the
# supported LuaApp graphics path. GAME verifies the local UGCGame graphics
# path. INPUT adds a real SDL-to-JNI click to that gate. NETWORK verifies an
# authenticated public-place join without exposing
@@ -360,7 +360,7 @@ SUPPORT_BUNDLE_DISABLED="${MOCKTAIL_DISABLE_SUPPORT_BUNDLE:-0}"
export MOCKTAIL_DISABLE_SUPPORT_BUNDLE=1
set +e
# Roblox may ignore SIGTERM while a native engine thread is
# active. Bound the smoke experiment even in that state so the caller gets a
# active. Bound the smoke run even in that state so the caller gets a
# deterministic result and no stale window/process is left behind. Tier C with
# an explicitly disabled present timer is the supported interactive launch,
# however, and must remain alive until the user closes it.
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Mocktail for Linux</title>
<link rel="stylesheet" href="styles.css">
</head>
<body><main>
<img src="../packaging/space.bigrat.mocktail.svg" alt="Mocktail logo">
<h1>Mocktail</h1>
<p>Play Roblox on Linux. Use the stable Flathub release, or try the latest nightly build from main.</p>
<a class="button" href="https://flathub.org/apps/space.bigrat.mocktail">Stable on Flathub</a>
<a class="button secondary" href="https://mocktail.bigrat.space/mocktail.flatpakref">Nightly build</a>
<div class="commands">
<p>Stable</p>
<code>flatpak install flathub space.bigrat.mocktail</code>
<p>Nightly</p>
<code>flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref</code>
</div>
<h2>AUR packages</h2>
<p>Available from the AUR. Choose one package:</p>
<p class="links">
<a href="https://aur.archlinux.org/packages/mocktail">mocktail</a>
<a href="https://aur.archlinux.org/packages/mocktail-bin">mocktail-bin</a>
<a href="https://aur.archlinux.org/packages/mocktail-git">mocktail-git</a>
</p>
<code>yay -S mocktail
yay -S mocktail-bin
yay -S mocktail-git</code>
<h2>DNF repository</h2>
<p>Stable and nightly packages for Fedora 44. Choose one package.</p>
<code>sudo curl -fsSL https://mocktail.bigrat.space/rpm/mocktail.repo -o /etc/yum.repos.d/mocktail.repo</code>
<div class="commands">
<p>Stable</p>
<code>sudo dnf install mocktail</code>
<p>Nightly</p>
<code>sudo dnf install mocktail-nightly</code>
</div>
<h2>APT repository</h2>
<p>Stable and nightly packages for Ubuntu 26.04. Choose one package.</p>
<code>sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://mocktail.bigrat.space/mocktail-packages.gpg -o /etc/apt/keyrings/mocktail.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/mocktail.gpg] https://mocktail.bigrat.space/apt mocktail main" | sudo tee /etc/apt/sources.list.d/mocktail.list &gt;/dev/null
sudo apt update</code>
<div class="commands">
<p>Stable</p>
<code>sudo apt install mocktail</code>
<p>Nightly</p>
<code>sudo apt install mocktail-nightly</code>
</div>
<h2>Direct downloads</h2>
<p>Prebuilt packages for Linux x86_64.</p>
<p class="download-label">Stable</p>
<p class="links">
<a href="https://github.com/komaruworld/mocktail/releases/latest/download/Mocktail-x86_64.AppImage">AppImage</a>
<a href="https://mocktail.bigrat.space/downloads/mocktail.deb">DEB</a>
<a href="https://mocktail.bigrat.space/downloads/mocktail.rpm">RPM</a>
</p>
<p class="download-label">Nightly</p>
<p class="links">
<a href="https://github.com/komaruworld/mocktail/releases/download/continuous/mocktail-nightly.AppImage">AppImage</a>
<a href="https://github.com/komaruworld/mocktail/releases/download/continuous/mocktail-nightly.deb">DEB</a>
<a href="https://github.com/komaruworld/mocktail/releases/download/continuous/mocktail-nightly.rpm">RPM</a>
</p>
<p><a class="source" href="https://github.com/komaruworld/mocktail">Source code</a></p>
<p>Mocktail is an independent community project and is not affiliated with Roblox Corporation.</p>
</main></body>
</html>
+88
View File
@@ -0,0 +1,88 @@
:root {
font: 17px/1.55 system-ui, sans-serif;
color-scheme: dark;
}
body {
margin: 0;
background: #0d1117;
color: #e6edf3;
}
main {
max-width: 680px;
margin: 12vh auto;
padding: 32px;
}
img {
width: 88px;
height: 88px;
}
h1 {
margin: .2rem 0;
font-size: clamp(2.5rem, 8vw, 4.5rem);
}
h2 {
margin: 32px 0 0;
font-size: 1.15rem;
}
p {
color: #9da7b3;
}
a.button {
display: inline-block;
margin: 16px 8px 16px 0;
padding: 12px 18px;
border-radius: 9px;
background: #238636;
color: white;
font-weight: 700;
text-decoration: none;
}
a.secondary {
background: #21262d;
}
.commands {
margin-top: 10px;
}
.commands p,
.download-label {
margin: 14px 0 5px;
color: #e6edf3;
font-size: .82rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
code {
display: block;
padding: 14px;
overflow-wrap: anywhere;
border: 1px solid #30363d;
border-radius: 6px;
background: #161b22;
white-space: pre-wrap;
}
.links {
margin: 10px 0 14px;
}
.links a {
display: inline-block;
margin: 0 14px 6px 0;
color: #e6edf3;
}
a.source {
color: #e6edf3;
}
+44 -6
View File
@@ -18,8 +18,13 @@
#include <algorithm>
#include <atomic>
#include <limits>
#include <new>
#if defined(__GLIBC__)
#include <dlfcn.h>
#endif
namespace mocktail::compat {
namespace {
@@ -31,6 +36,34 @@ struct ThreadStartContext {
std::atomic<NativeThreadInitializer> g_thread_initializer{nullptr};
size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
#if defined(__GLIBC__)
using GetStaticTlsInfo = void (*)(size_t*, size_t*);
static const auto get_static_tls_info =
reinterpret_cast<GetStaticTlsInfo>(
dlsym(RTLD_DEFAULT, "_dl_get_tls_static_info"));
size_t tls_size = 0;
size_t tls_alignment = 0;
if (get_static_tls_info != nullptr) {
get_static_tls_info(&tls_size, &tls_alignment);
}
// glibc places static TLS inside the requested mapping. Compensate only
// unusually large blocks; Mocktail's compatibility TLS is several MiB.
constexpr size_t kLargeStaticTlsThreshold = 1U * 1024U * 1024U;
if (tls_size > kLargeStaticTlsThreshold && tls_alignment != 0 &&
(tls_alignment & (tls_alignment - 1)) == 0 &&
tls_size <= std::numeric_limits<size_t>::max() - (tls_alignment - 1)) {
const size_t reserve =
(tls_size + tls_alignment - 1) & ~(tls_alignment - 1);
if (guest_stack_size <= std::numeric_limits<size_t>::max() - reserve) {
return guest_stack_size + reserve;
}
}
#endif
return guest_stack_size;
}
int CopySupportedThreadAttributes(const pthread_attr_t& source,
pthread_attr_t* destination) noexcept {
int detach_state = PTHREAD_CREATE_JOINABLE;
@@ -62,7 +95,8 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
if (result != 0) {
return result;
}
result = pthread_attr_setstacksize(destination, stack_size);
result = pthread_attr_setstacksize(destination,
HostStackSizeForGuest(stack_size));
if (result != 0) {
return result;
}
@@ -112,7 +146,8 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source,
if (result != 0) {
return result;
}
return pthread_attr_setstacksize(destination, stack_size);
return pthread_attr_setstacksize(destination,
HostStackSizeForGuest(stack_size));
}
int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
@@ -139,9 +174,11 @@ int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
if (result != 0) {
return result;
}
const size_t guest_floor =
std::max(guest_stack_size, kBionicLp64FallbackThreadStackSize);
return pthread_attr_setstacksize(
destination, std::max({guest_stack_size, host_default_stack_size,
kBionicLp64FallbackThreadStackSize}));
destination,
std::max(host_default_stack_size, HostStackSizeForGuest(guest_floor)));
}
void* RunGuestThread(void* raw_context) noexcept {
@@ -183,8 +220,9 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
if (result == 0) {
normalized_attr_initialized = true;
if (attr == nullptr) {
result = pthread_attr_setstacksize(&normalized_attr,
kBionicLp64DefaultThreadStackSize);
result = pthread_attr_setstacksize(
&normalized_attr,
HostStackSizeForGuest(kBionicLp64DefaultThreadStackSize));
} else {
result = CopySupportedThreadAttributes(*attr, &normalized_attr);
}
+2 -2
View File
@@ -200,7 +200,7 @@ InstallHostAbiExperiment(uintptr_t library_base, const HostAbiProfile &profile,
result.uses_native_mimalloc = !options.install_allocator_bridges;
if (library_base == 0 || profile.bridge_entry_count == 0 ||
profile.bridge_entry_count > profile.bridge_entries.size()) {
result.error = "invalid host ABI experiment profile";
result.error = "invalid host ABI profile";
return result;
}
@@ -240,7 +240,7 @@ InstallHostAbiExperiment(uintptr_t library_base, const HostAbiProfile &profile,
std::cout << std::flush;
result.installed = ok;
if (!ok) {
result.error = "one or more host ABI experiment actions failed";
result.error = "one or more host ABI actions failed";
}
return result;
}
+6 -13
View File
@@ -953,21 +953,14 @@ bool ValidateReceipt(const std::string& receipt_path,
return false;
}
const BuildIdResult runtime_build_id = ReadElfBuildId("/proc/self/exe");
const FileSha256Result runtime_hash = ComputeFileSha256("/proc/self/exe");
const FileSha256Result manifest_hash =
ComputeFileSha256(compatibility_manifest_path);
if (!runtime_build_id || !runtime_hash || !manifest_hash) {
*error =
"cannot identify runtime or hash compatibility manifest for approval "
"receipt";
if (!manifest_hash) {
*error = "cannot hash compatibility manifest for approval receipt";
return false;
}
if (receipt_runtime_build_id != runtime_build_id.build_id ||
receipt_runtime_sha256 != runtime_hash.sha256 ||
receipt_manifest_sha256 != manifest_hash.sha256) {
*error =
"approval receipt belongs to different runtime or compatibility bytes";
if (receipt_manifest_sha256 != manifest_hash.sha256) {
*error = "approval receipt belongs to different compatibility bytes";
return false;
}
@@ -990,7 +983,7 @@ bool ValidateReceipt(const std::string& receipt_path,
if (!ValidateCanaryAttestation(
*canonical_attestation, index + 1, payload_id, payload_fingerprint,
profile_sha256, manifest_hash.sha256, canary_runtime_sha256,
runtime_build_id.build_id, &canary_evidence[index], error)) {
receipt_runtime_build_id, &canary_evidence[index], error)) {
return false;
}
}
@@ -1018,7 +1011,7 @@ bool ValidateReceipt(const std::string& receipt_path,
std::string generation_evidence;
generation_evidence.append("runtime_build_id=")
.append(runtime_build_id.build_id)
.append(receipt_runtime_build_id)
.append("\n");
generation_evidence.append("payload=")
.append(payload_fingerprint)
+1 -1
View File
@@ -62,7 +62,7 @@ PayloadCompatibilityResult CheckPayloadCompatibility(
result.error = "This Roblox build is not enabled for normal runs: " +
result.profile.reason +
"\n Use --allow-unverified-build only for an explicit "
"compatibility experiment.";
"compatibility run.";
result.profile = {};
return result;
}
-1
View File
@@ -82,7 +82,6 @@ BackendSelection SelectGraphicsBackend(
reason};
}
// Fallback order remains Vulkan-first and deterministic.
constexpr GraphicsBackendKind kFallbackOrder[] = {
GraphicsBackendKind::kAngleVulkan,
GraphicsBackendKind::kDirectVulkan,
+6 -24
View File
@@ -12,8 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// src/jnivm/jnivm.cc — Implementation of the Pseudo Java Native Interface VM.
#include "jnivm/jnivm.h"
#include <algorithm>
@@ -35,8 +33,6 @@
namespace jnivm {
// Class implementation
void Class::RegisterMethod(const std::string& method_name,
const std::string& signature,
MethodCallback callback) {
@@ -71,7 +67,6 @@ thread_local JNIEnv* g_thread_local_env = nullptr;
thread_local JNIEnv g_thread_env_storage = {};
thread_local VM* g_thread_vm_instance = nullptr;
// Thread-local pointer to the active VM instance for JNI callback resolution.
VM* g_vm_instance = nullptr;
std::recursive_mutex g_jni_state_mutex;
@@ -495,8 +490,7 @@ RobloxAuthIdentity ResearchRobloxIdentityFromEnvironment() {
return identity;
}
// Temporary research-only compatibility path. Production startup should
// inject a resolved identity through VM::SetRobloxAuthIdentity instead.
// Normal startup injects identity through VM::SetRobloxAuthIdentity.
identity.user_id = static_cast<int64_t>(parsed);
const char* username = std::getenv("MOCKTAIL_ROBLOX_USERNAME");
if (username && username[0] != '\0') {
@@ -557,8 +551,7 @@ std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
return FallbackClassForName(cls->GetName());
}
// Stores a shared_ptr into my_segment, keeping it alive in g_segment_owners.
// Returns the encoded segment-table handle cast to jclass.
// g_segment_owners keeps the encoded class handle alive.
static jclass StoreClass(std::shared_ptr<Class> cls) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
Class* raw_ptr = cls.get();
@@ -593,9 +586,8 @@ PseudoJavaObject* PseudoObjectFromRef(jobject obj) {
raw_ptr = reinterpret_cast<Object*>(obj);
}
if (!raw_ptr) return nullptr;
// Perform a static cast instead of dynamic_cast to avoid calling the host
// __dynamic_cast against potentially-Bionic RTTI structures. All objects
// stored in our segment table are PseudoJavaObjects by construction.
// Avoid host __dynamic_cast on potentially Bionic RTTI; segment entries are
// PseudoJavaObjects by construction.
return static_cast<PseudoJavaObject*>(raw_ptr);
}
@@ -840,8 +832,7 @@ std::string JavaStringUtf8Bytes(const std::vector<jchar>& utf16) {
code_point = 0x10000 + ((code_point - 0xd800) << 10) +
(utf16[++index] - 0xdc00);
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
// java.nio.charset.CharsetEncoder replaces each malformed UTF-16 input
// unit with its configured UTF-8 replacement, the single byte '?'.
// CharsetEncoder replaces each malformed UTF-16 unit with '?'.
output.push_back('?');
continue;
}
@@ -5082,9 +5073,7 @@ JNIEnv* VM::GetJNIEnv() {
}
void VM::RestoreFunctions() {
// Force env->functions back to our pseudo-VM table on ALL known JNIEnv*.
// libroblox.so replaces env->functions with a Bionic JNI table during
// JNI_OnLoad; we need to restore it before Stage 6 calls.
// JNI_OnLoad replaces env->functions; restore all known environments.
java_vm_storage_.functions = &invoke_interface_;
java_vm_ = &java_vm_storage_;
if (jni_env_) {
@@ -5121,10 +5110,6 @@ std::shared_ptr<Class> VM::FindClass(const std::string& class_name) const {
}
void VM::InitJNIFunctionTables() {
// JNIInvokeInterface_ — the JavaVM function table.
// Most functions are no-ops in this pseudo-VM; only AttachCurrentThread
// is meaningful for single-threaded prototype use.
invoke_interface_.AttachCurrentThread =
[](JavaVM* vm, void** env, void* /*args*/) -> jint {
if (JniVmTraceEnabled()) {
@@ -5207,9 +5192,6 @@ void VM::InitJNIFunctionTables() {
java_vm_storage_.functions = &invoke_interface_;
java_vm_ = &java_vm_storage_;
// JNINativeInterface_ — the JNIEnv function table.
// Only the minimal subset required by Roblox's JNI_OnLoad is stubbed here.
native_interface_.FindClass =
[](JNIEnv* /*env*/, const char* name) -> jclass {
if (JniVmTraceEnabled()) {
+60 -136
View File
@@ -12,18 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Transitional Sober compatibility runtime. This file preserves the current
// reverse-engineered startup path while upstream-backed modules replace
// it one boundary at a time.
//
// Implements the preserved 5-stage initialisation pipeline:
//
// Stage 1: Initialise Pseudo-JVM (libjnivm).
// Stage 2: Register mandatory Android SDK JNI classes.
// Stage 3: Load host system libraries under Bionic alias names.
// Stage 4: Map libroblox.so into process address space via the linker.
// Stage 5: Invoke JNI_OnLoad and hand control to the game engine.
#include <algorithm>
#include <atomic>
#include <cctype>
@@ -76,6 +64,7 @@
#include "linker/linker.h"
#include "mocktail/graphics/bionic_egl_bridge.h"
#include "runtime/environment.h"
#include "runtime/discord_rpc.h"
#include "runtime/jnivm_platform_web_callbacks.h"
#include "runtime/owned_pthread.h"
#include "runtime/roblox_app_lifecycle.h"
@@ -399,7 +388,6 @@ extern "C" int mocktail_recover_stack_chk_fail() {
return 0;
}
// JNI_OnLoad function pointer type as specified by the JNI standard.
using JniOnLoadFn = jint (*)(JavaVM*, void*);
using NativeGameGlobalInitFn = void (*)(JNIEnv*, jclass);
using NativeInitClientSettingsFn = jint (*)(JNIEnv*, jclass, jstring, jstring,
@@ -2518,10 +2506,7 @@ static void* GetThreadScratchBuffer(pid_t tid) {
return g_thread_scratch_pool[0].buffer;
}
// Fallback vtable used when a null/invalid object pointer is dereferenced
// during a vtable dispatch in Stage 6. Each slot is a no-op stub that
// returns 0 so that the caller can continue execution. The table covers
// all vtable offsets seen in libroblox (currently up to +0xf8 = slot 31).
// Covers every libroblox vtable offset observed through slot 31 (+0xf8).
static uintptr_t NullVtableStub() { return 0; }
static const uintptr_t kFallbackVtable[32] = {
reinterpret_cast<uintptr_t>(&NullVtableStub),
@@ -2557,8 +2542,7 @@ static const uintptr_t kFallbackVtable[32] = {
reinterpret_cast<uintptr_t>(&NullVtableStub),
reinterpret_cast<uintptr_t>(&NullVtableStub),
};
// Fake object whose first 8 bytes point to kFallbackVtable.
// Used to satisfy `mov rax,[rsi]; call [rax+offset]` when RSI=null.
// Lets a recovered `mov rax,[rsi]` continue through the fallback vtable.
static const uintptr_t kFallbackObject[4] = {
reinterpret_cast<uintptr_t>(kFallbackVtable), 0, 0, 0,
};
@@ -2809,12 +2793,10 @@ void ApplyRuntimeDefaults() {
SetEnvDefault("MOCKTAIL_ENGINE_DETACH", "0");
SetEnvDefault("MOCKTAIL_INIT_CLIENT_SETTINGS", "0");
SetEnvDefault("MOCKTAIL_POST_CLIENT_SETTINGS", "0");
// Sober-compatible fallback: allow unauthenticated startup (LuaApp path)
// unless explicitly disabled.
SetEnvDefault("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
// Current Roblox Android builds drive app startup through NativeGLInterface's
// V2 app bridge path. The legacy NativeAppBridgeInterface entry point stays
// available for experiments, but running it in parallel can block V2 init.
// available by opt-in, but running it in parallel can block V2 init.
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "0");
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START_THREAD", "0");
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM", "1");
@@ -5823,8 +5805,8 @@ extern "C" void* mocktail_roblox_allocator_object_alloc_bridge(void*,
return mocktail_roblox_aligned_alloc_bridge(size, align);
}
// Retained only for the legacy fixed-offset allocator patch below. Current
// Build-ID experiments own their seed storage in compat/host_abi_experiment.
// Retained for the legacy fixed-offset allocator patch. Build-ID profiles keep
// their seed storage in compat/host_abi_experiment.
uintptr_t g_mocktail_roblox_allocator_object_vtable[4] = {};
uintptr_t g_mocktail_roblox_allocator_object[1] = {
reinterpret_cast<uintptr_t>(g_mocktail_roblox_allocator_object_vtable)};
@@ -5901,7 +5883,7 @@ bool InstallActiveHostAbiExperiment(uintptr_t libroblox_base) {
}
g_host_abi_install_attempted = true;
if (!HostAbiExperimentRequested()) {
std::cout << " [compat] host ABI experiment disabled by policy\n"
std::cout << " [compat] host ABI profile disabled by policy\n"
<< std::flush;
return false;
}
@@ -5909,7 +5891,7 @@ bool InstallActiveHostAbiExperiment(uintptr_t libroblox_base) {
const mocktail::compat::HostAbiProfile* profile =
g_active_host_abi_profile.load(std::memory_order_acquire);
if (libroblox_base == 0 || profile == nullptr) {
std::cerr << " [compat] host ABI experiment has no active profile/base\n"
std::cerr << " [compat] host ABI install has no active profile/base\n"
<< std::flush;
return false;
}
@@ -6533,7 +6515,7 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
// Host alloc bridges must be in place before any constructor executes.
if (HostAbiExperimentRequested() && !InstallActiveHostAbiExperiment(base)) {
std::cerr << " [compat] host ABI experiment failed before constructors\n"
std::cerr << " [compat] host ABI install failed before constructors\n"
<< std::flush;
return;
}
@@ -6633,7 +6615,6 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
uintptr_t* init_array =
reinterpret_cast<uintptr_t*>(base + init_array_offset);
// Make the page writable just in case
long ctor_page_size = sysconf(_SC_PAGESIZE);
if (ctor_page_size > 0) {
uintptr_t addr = reinterpret_cast<uintptr_t>(init_array);
@@ -6647,7 +6628,6 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
g_original_ctors.reserve(kCtorCount);
for (size_t i = 0; i < kCtorCount; ++i) {
g_original_ctors.push_back(init_array[i]);
// Skip the first constructor if env flag is set, otherwise wrap it
if (i == 0 && IsEnabled("MOCKTAIL_PATCH_FIRST_CONSTRUCTOR")) {
static void (*noop_fn)() = []() {};
g_original_ctors[i] = reinterpret_cast<uintptr_t>(noop_fn);
@@ -26925,26 +26905,21 @@ void HeadlessSegvHandler(int signo, siginfo_t* info, void* context) {
return;
}
// libGLdispatch crash recovery (handles Bionic TLS incompatibility in Glibc threads).
// Matches any SIGSEGV occurring inside libGLdispatch.so.0.
// libGLdispatch may access Bionic TLS keys through glibc thread state.
if (g_current_stage >= 6 && module_name && std::strstr(module_name, "libGLdispatch") != nullptr) {
// If the instruction is a 64-bit store (mov [reg], reg) or load (mov reg, [reg]):
if (instruction[0] == 0x48 && (instruction[1] == 0x89 || instruction[1] == 0x8b)) {
int rm = instruction[2] & 7;
int reg_map[] = { REG_RAX, REG_RCX, REG_RDX, REG_RBX, -1, -1, REG_RSI, REG_RDI };
if (rm < 8 && reg_map[rm] != -1) {
uintptr_t old_val = ucontext->uc_mcontext.gregs[reg_map[rm]];
// Only redirect if the register is currently NULL, very low, or points to a read-only stub/vtable.
if (old_val < kStage5LowAddressThreshold) {
// Get or allocate a thread-specific writable buffer from our pool.
// This avoids calling malloc (which corrupts the heap if interrupted)
// and avoids sharing the same static buffer across multiple threads (which causes corruption).
// Signal handlers cannot call malloc; each thread gets fixed scratch
// storage to avoid sharing writes across recovered threads.
pid_t tid = static_cast<pid_t>(syscall(SYS_gettid));
void* thread_scratch = GetThreadScratchBuffer(tid);
ucontext->uc_mcontext.gregs[reg_map[rm]] = reinterpret_cast<greg_t>(thread_scratch);
} else if (old_val == reinterpret_cast<uintptr_t>(&NullVtableStub) ||
old_val == reinterpret_cast<uintptr_t>(kFallbackVtable)) {
// If it was redirected to a read-only stub/vtable, redirect it to a thread-specific writable buffer.
pid_t tid = static_cast<pid_t>(syscall(SYS_gettid));
void* thread_scratch = GetThreadScratchBuffer(tid);
ucontext->uc_mcontext.gregs[reg_map[rm]] = reinterpret_cast<greg_t>(thread_scratch);
@@ -27015,7 +26990,7 @@ void HeadlessSegvHandler(int signo, siginfo_t* info, void* context) {
}
skip_stage6_null_object_immediate_store:
// Broader: any `48 89 ??` store with low dest-addr (catches all register combos).
// Catch the remaining `48 89 ??` stores to low addresses.
if (g_current_stage >= 6 && info &&
reinterpret_cast<uintptr_t>(info->si_addr) < kStage5LowAddressThreshold &&
instruction[0] == 0x48 && instruction[1] == 0x89) {
@@ -27025,10 +27000,9 @@ skip_stage6_null_object_immediate_store:
write(2, msg, sizeof(msg) - 1);
}
++g_skipped_headless_null_writes;
// Determine instruction length (modrm) — skip the store.
// 48 89 /r — 3 or 4 bytes depending on displacement.
uint8_t modrm = instruction[2];
uint8_t mod = (modrm >> 6) & 0x3;
// The ModR/M displacement determines how many instruction bytes to skip.
int instr_len = (mod == 1) ? 4 : (mod == 2) ? 7 : 3;
ucontext->uc_mcontext.gregs[REG_RIP] += instr_len;
return;
@@ -27053,9 +27027,7 @@ skip_stage6_null_object_immediate_store:
reinterpret_cast<uintptr_t>(info->si_addr) == 0 &&
instruction[0] == 0x48 && instruction[1] == 0x8b &&
instruction[2] == 0x06) {
// Pattern: mov rax,[rsi] with RSI=null, often followed by call [rax+N].
// Point RSI at a fake object whose vtable is kFallbackVtable so that the
// load succeeds and subsequent indirect calls land in NullVtableStub.
// Redirect the null receiver so a following vtable call reaches the stub.
if (g_skipped_headless_null_writes == 0) {
const char msg[] =
" [patch] redirected Stage6 mov rax,[rsi] (RSI=null) to fallback object\n";
@@ -27064,13 +27036,11 @@ skip_stage6_null_object_immediate_store:
++g_skipped_headless_null_writes;
ucontext->uc_mcontext.gregs[REG_RSI] =
reinterpret_cast<greg_t>(kFallbackObject);
// Do NOT advance RIP — re-execute the mov against the now-valid RSI.
// Re-run the load against the repaired receiver.
return;
}
// Broader fallback: any Stage6 null/low-address load through RSI into RAX
// that is the exact 3-byte form `48 8b 06` — recover by pointing RSI at
// fallback vtable object so the call chain can continue.
// The same recovery applies to nonzero addresses below the valid threshold.
if (g_current_stage >= 6 && info &&
reinterpret_cast<uintptr_t>(info->si_addr) < kStage5LowAddressThreshold &&
instruction[0] == 0x48 && instruction[1] == 0x8b &&
@@ -27106,8 +27076,7 @@ skip_stage6_null_object_immediate_store:
return;
}
// Also handle `mov rax,[rax]` (48 8b 00) / `mov rax,[rax+N]` (48 8b 40 NN)
// with low RAX — redirect RAX to kFallbackVtable.
// Redirect low `mov rax,[rax+N]` bases to the fallback vtable.
if (g_current_stage >= 6 && info &&
reinterpret_cast<uintptr_t>(info->si_addr) < kStage5LowAddressThreshold &&
instruction[0] == 0x48 && instruction[1] == 0x8b &&
@@ -27279,12 +27248,8 @@ skip_stage6_null_object_immediate_store:
return;
}
// SI_KERNEL SIGTRAP (si_code=128): kernel-generated trap, commonly a
// shadow-stack (CET/SHSTK) return-address mismatch on Roblox's internal
// threads. The instruction at RIP is not an int3 — it's the instruction
// that would have executed after the bad return. We cannot safely continue
// from here; if a recovery jmp_buf is armed on this thread use it, otherwise
// just exit the offending thread so the rest of the engine keeps running.
// SI_KERNEL SIGTRAP commonly follows a CET shadow-stack return mismatch.
// Use a thread-local recovery target when one is armed.
if (signo == SIGTRAP && info && info->si_code == SI_KERNEL &&
g_current_stage >= 6) {
if (libroblox_base != 0 &&
@@ -27323,17 +27288,11 @@ skip_stage6_null_object_immediate_store:
write(2, msg, sizeof(msg) - 1);
siglongjmp(g_start_app_with_params_jmp_buf, 1);
}
// No recovery target on this thread — skip the trap by advancing RIP past
// the current instruction and returning. Killing the thread via
// pthread_exit would hang the engine since Roblox waits on these threads.
// SI_KERNEL SIGTRAP here is a shadow-stack (CET) mismatch on Roblox's
// internal threads; skipping it lets execution continue from the instruction
// after the bad return address.
// Roblox may wait on this thread, so exiting it can hang the engine.
const char msg[] =
" [patch] SI_KERNEL SIGTRAP: no recovery, skipping instruction\n";
write(2, msg, sizeof(msg) - 1);
// Advance past the current instruction — for SI_KERNEL traps the faulting
// instruction is still at RIP (not executed yet), so +1 moves past it.
// SI_KERNEL leaves RIP on the instruction that has not run yet.
ucontext->uc_mcontext.gregs[REG_RIP] += 1;
return;
}
@@ -28487,9 +28446,8 @@ bool PatchStartAppDebugTrap(void* native_start_app_with_params) {
return false;
}
// NOTE: We intentionally do NOT patch the function entry point with `ret`.
// Doing so would silently kill the real startup path. Only the known-bad
// diagnostic tail block inside the function is bypassed here.
// Patch only the diagnostic tail; replacing the entry point would disable
// real startup.
constexpr uintptr_t kNativeStartAppOffset = 0x250436d;
constexpr uintptr_t kTailPatchOffset = 0x2504567;
constexpr unsigned char kTailJumpPatch[] = {
@@ -30532,9 +30490,7 @@ void* EngineStartupThread(void* arg) {
<< std::flush;
mocktail::window::MakeCurrentOnThread();
}
// Force our pseudo-VM function table on the env that EngineStartupThread will use.
// libroblox.so replaces env->functions during JNI_OnLoad; we restore it here so
// that our NewStringUTF / GetStringUTFChars helpers work correctly in Stage 6.
// JNI_OnLoad replaces env->functions; Stage 6 needs the pseudo-VM table.
if (env && context->vm) {
context->vm->RestoreFunctions();
env = context->vm->GetJNIEnv();
@@ -31922,6 +31878,17 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
const mocktail::runtime::ProcessEnvironment process_environment;
const mocktail::runtime::RuntimeConfig runtime_config =
mocktail::runtime::RuntimeConfig::FromEnvironment(process_environment);
mocktail::runtime::DiscordRpcSession discord_rpc(
runtime_config.discord_rpc());
if (runtime_config.discord_rpc().enabled) {
std::string discord_rpc_detail;
if (discord_rpc.Start(&discord_rpc_detail)) {
std::cout << " [discord-rpc] enabled; browsing activity queued\n";
} else {
std::cerr << " [discord-rpc] unavailable: " << discord_rpc_detail
<< "; continuing without Rich Presence\n";
}
}
const mocktail::runtime::InputCapabilityConfig& input_capabilities =
runtime_config.input_capabilities();
if (!runtime_config.frame_rate().valid()) {
@@ -32038,7 +32005,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cerr << "[FATAL] This Roblox build is not enabled for normal runs: "
<< build_profile.reason << '\n'
<< " Use --allow-unverified-build only for an explicit "
"compatibility experiment.\n";
"compatibility check.\n";
return EXIT_FAILURE;
}
@@ -32052,10 +32019,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cout << " [trace] full tracing enabled\n" << std::flush;
}
// Disable shadow-stack (CET/SHSTK) for this process before loading
// libroblox. Roblox's internal threads trigger SIGTRAP SI_KERNEL from
// return-address mismatches on the shadow stack; disabling it here prevents
// those spurious traps. This is a no-op on kernels that don't support CET.
// Roblox internal threads trigger SI_KERNEL traps from CET shadow-stack
// return mismatches. Unsupported kernels ignore this request.
{
long r = syscall(SYS_arch_prctl, ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK);
if (r == 0) {
@@ -32074,7 +32039,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
setenv("MOCKTAIL_APP_BRIDGE_HEADLESS_INIT_PARAMS", is_headless ? "1" : "0",
1);
}
// Stage 1: Initialise the Bionic-compatible ELF linker + Pseudo-JVM.
PrintStage(1, "Initialising Bionic linker + Pseudo-JVM");
#ifdef MOCKTAIL_USE_BIONIC_LINKER
@@ -32089,8 +32053,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
}
std::cout << " Pseudo-JVM instance created successfully.\n";
// Stage 2: Register mandatory Android SDK JNI classes.
// Roblox's JNI_OnLoad will call FindClass for these at startup.
// FindClass needs these descriptors before JNI_OnLoad.
PrintStage(2, "Registering Android SDK JNI classes");
auto context_class = jni_vm->RegisterClass("android/content/Context");
@@ -32104,7 +32067,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cout << " [JNI callback] nativeInitClientSettings invoked\n";
});
// Classes required by Stage 6 startup entry points.
auto native_gl_interface_class =
jni_vm->RegisterClass("com/roblox/engine/jni/NativeGLInterface");
g_native_gl_class_for_main_thread =
@@ -32178,10 +32140,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cout << " Registered " << jni_vm->GetClassCount()
<< " JNI class(es)\n";
// Window init (non-headless mode): create the SDL3 + EGL window before
// Android/EGL stubs are preloaded, so SDL resolves the real host graphics
// backend. By default the window stays hidden until Roblox presents a real
// frame.
// Create SDL/EGL before loading Android stubs so SDL binds the host graphics
// backend. The window stays hidden until the first real frame.
bool window_initialised = false;
if (!is_headless) {
const int win_w = runtime_config.window().width;
@@ -32238,15 +32198,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
}
}
// Stage 3: Populate the Bionic symbol table with all stub + system
// symbols so that mcpelauncher-linker can resolve libroblox.so's
// DT_NEEDED entries without calling the host Glibc dynamic linker.
//
// Strategy:
// 1. dlopen each stub .so with RTLD_GLOBAL (host linker finds them
// via RPATH=$ORIGIN).
// 2. Iterate over known stub symbols and register them into the
// Bionic symbol table via linker::RegisterSymbol().
PrintStage(3, "Building Bionic symbol table from stubs");
libc_shim::Install();
@@ -32287,7 +32238,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
}
}
// Stub libraries to preload. All located via RPATH=$ORIGIN.
std::vector<const char*> stub_names = {
"libc.so", "libdl.so", "libm.so", "libz.so",
"libandroid.so", "liblog.so", "libmediandk.so",
@@ -32331,15 +32281,9 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
real_gles_handle = OpenRealGlesLibrary();
}
// Register every known Android NDK / system symbol into the Bionic
// symbol table so mcpelauncher-linker resolves them correctly.
//
// We prioritize resolving from our loaded stub libraries first.
// This ensures Bionic shims (like custom pthread structure handlers
// or __sF stdio compatibility) are preferred over host glibc ones,
// preventing layout incompatibility and crashes.
// Prefer Bionic shims over glibc for guest layouts such as pthread and FILE.
static const char* kSymbolsToRegister[] = {
// Exported stub functions and structures
// Symbols exported by the Android compatibility stubs.
"AAsset_close",
"AAsset_getBuffer",
"AAsset_getLength",
@@ -32597,7 +32541,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
"__system_property_get",
"__write_chk",
// Binary-compatible standard pthread symbols not explicitly shimmed
// pthread functions whose host ABI is already compatible.
"pthread_once",
"pthread_self",
"pthread_equal",
@@ -32614,7 +32558,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
"pthread_sigmask",
"pthread_setname_np",
// Standard C library functions needed by libroblox.so
// Standard C library functions resolved from host libc.
"strcmp",
"strncmp",
"strcpy",
@@ -32926,13 +32870,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
}
}
// Stage 4: Load libroblox.so via the Bionic-compatible linker.
// Auto-register ALL undefined symbols from the target library BEFORE
// loading stub libraries into the Bionic linker. This ensures the stubs'
// injected symbol maps include every symbol libroblox.so will need at
// relocation time. Auto-registration must run first because load_library
// takes a snapshot of g_bionic_symbols at call time.
// Register imports before loading synthetic SONAMEs; load_library snapshots
// g_bionic_symbols.
{
int fd = ::open(library_path.c_str(), O_RDONLY);
if (fd >= 0) {
@@ -32942,7 +32881,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
PROT_READ, MAP_PRIVATE, fd, 0);
if (map != MAP_FAILED) {
auto* ehdr = reinterpret_cast<Elf64_Ehdr*>(map);
// Walk section headers to find .dynsym and .dynstr
auto* shdr = reinterpret_cast<Elf64_Shdr*>(
reinterpret_cast<char*>(map) + ehdr->e_shoff);
Elf64_Shdr* dynsym_hdr = nullptr;
@@ -32963,7 +32901,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (syms[si].st_shndx != SHN_UNDEF) continue;
const char* name = strtab + syms[si].st_name;
if (!name || name[0] == '\0') continue;
// Skip if already registered
if (linker::GetBionicSymbols().count(name)) continue;
auto result = ResolveSymbolForBionic(name, has_window,
real_gles_handle,
@@ -32984,9 +32921,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
}
}
// Register stub libraries inside Bionic linker. This must happen AFTER
// auto-registration so that every symbol libroblox needs is already in
// g_bionic_symbols when load_library snapshots the map.
// Load synthetic libraries only after the import map is complete.
linker::RegisterBionicPthreadKeyRuntimeForLibc();
linker::RegisterBionicSysconfRuntimeForLibc();
linker::RegisterBionicSignalRuntimeForLibc();
@@ -33026,12 +32961,11 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cout << " [linker] Bionic unwind metadata validated for libroblox\n"
<< std::flush;
// The pre-constructor linker hook is the only installation phase. A second
// arena initialization here would not be idempotent; post-load only verifies
// that the experiment completed.
// The pre-constructor hook owns installation; repeating arena setup here is
// not idempotent.
if (HostAbiExperimentRequested() &&
(!g_host_abi_install_attempted || !g_host_abi_install_result)) {
std::cerr << "\n[FATAL] Host ABI experiment did not install before "
std::cerr << "\n[FATAL] Host ABI profile did not install before "
"libroblox constructors: "
<< (g_host_abi_install_result.error != nullptr
? g_host_abi_install_result.error
@@ -33040,7 +32974,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
return EXIT_FAILURE;
}
// Stage 5: Invoke JNI_OnLoad — hand control to the game engine.
PrintStage(5, "Invoking JNI_OnLoad in libroblox.so");
auto* jni_onload =
@@ -33218,9 +33151,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
}
}
// libroblox.so may have replaced env->functions during JNI_OnLoad with a
// Bionic JNI table. Restore our pseudo-VM function table so that our Stage 6
// helpers (NewStringUTF, GetStringUTFChars, etc.) work correctly.
// Restore the pseudo-JNI table if JNI_OnLoad replaced it.
{
JNIEnv* main_env = jni_vm->GetJNIEnv();
if (main_env) {
@@ -33843,13 +33774,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cout << " [compat] skipped fixed-offset Stage 6 patch set\n"
<< std::flush;
}
// By default, all startup steps are enabled when MOCKTAIL_START_ENGINE=1.
// Real (non-mock) calls to nativeSetAssetPath,
// nativeAppBridgeV2InitWithParams and nativeAppBridgeV2StartAppWithParams
// are now the default path — the mock/bypass path only activates when the
// real call crashes and recovery fires via siglongjmp. The old
// MOCKTAIL_UNSAFE_NATIVE_STARTUP guard is removed so users do not need to
// set it explicitly.
// Real startup calls run by default; signal recovery falls back to the
// Mocktail implementations.
const bool requested_set_asset_path =
ShouldRunStartupStep("MOCKTAIL_STEP_SET_ASSET_PATH", true);
const bool requested_init_with_params =
@@ -33857,8 +33783,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
const bool requested_start_app_with_params =
ShouldRunStartupStep("MOCKTAIL_STEP_START_APP_WITH_PARAMS", true);
const bool run_set_asset_path = requested_set_asset_path;
// call_real_set_asset_path: enabled by default unless explicitly disabled.
// The signal handler will catch any crash and fall back to MocktailSetAssetPath.
const bool call_real_set_asset_path =
run_set_asset_path &&
!IsDisabled("MOCKTAIL_CALL_REAL_NATIVE_SET_ASSET_PATH");
@@ -33876,7 +33800,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
IsEnabled("MOCKTAIL_APP_BRIDGE_APP_START")) &&
native_app_bridge_app_start != nullptr;
const bool run_init_with_params = requested_init_with_params;
// call_real_init_with_params: real call is the default; mock only on crash.
const bool call_real_init_with_params =
run_init_with_params &&
!IsDisabled("MOCKTAIL_CALL_REAL_APP_BRIDGE_INIT");
@@ -33922,8 +33845,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
(native_pass_current_display_refresh_rate != nullptr ||
native_pass_supported_refresh_rates != nullptr);
const bool run_start_app_with_params = requested_start_app_with_params;
// call_real_start_app_with_params: opt-in because the real call can block
// startup before any rendering path becomes visible.
// Keep the real call opt-in because it can block before rendering starts.
const bool call_real_start_app_with_params =
run_start_app_with_params &&
IsEnabled("MOCKTAIL_CALL_REAL_APP_BRIDGE_START");
@@ -34052,7 +33974,9 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
environment, message_bus_symbols, platform_web_symbols.web_view,
platform_web_symbols.browser_service, *experience_game_symbols,
jni_factory, present_boundary, std::move(surface_config),
&dependencies.roblox_credential());
&dependencies.roblox_credential(),
mocktail::runtime::RobloxExperienceSurfaceProvider{},
discord_rpc.observer());
const mocktail::Status platform_protocol_status =
experience_composition->InitializePlatformProtocols();
if (!platform_protocol_status.ok()) {
@@ -34533,7 +34457,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (!build_profile.default_allowed) {
std::cerr
<< "[FATAL] Compatibility experiment returned, but Build ID "
<< "[FATAL] Compatibility run returned, but Build ID "
<< build_profile.elf_build_id
<< " has not passed the readiness gates; refusing a success exit.\n";
return EXIT_FAILURE;
+2 -7
View File
@@ -12,8 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// src/libc_shim/libc_shim.cc — Bionic libc → Glibc shim implementation.
#include "libc_shim/libc_shim.h"
#include <array>
@@ -33,7 +31,6 @@ namespace libc_shim {
namespace {
// Registered prefix mappings: android_prefix → host_prefix.
std::unordered_map<std::string, std::string> g_path_mappings;
bool g_installed = false;
@@ -504,10 +501,8 @@ extern "C" ssize_t mocktail_readlink(const char* path, char* buf,
extern "C" ssize_t mocktail___readlink_chk(const char* path, char* buf,
size_t len, size_t buf_len) {
// Match Bionic's fortify.cpp contract: both an impossible ssize_t result
// and a compiler-proven destination overrun are fatal programming errors,
// not recoverable readlink failures. Returning EINVAL here used to let the
// guest continue after a detected buffer overflow.
// Bionic treats impossible ssize_t results and proven overruns as fatal;
// returning EINVAL would continue after a detected buffer overflow.
if (len > static_cast<size_t>(SSIZE_MAX)) {
std::fprintf(stderr,
"FORTIFY: readlink: size %zu > SSIZE_MAX\n", len);
+9 -25
View File
@@ -6,11 +6,7 @@
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// src/linker/linker.cc — ELF loader implementation.
//
// Stage A (no mcpelauncher-linker): delegates to host dlopen/dlsym.
// Stage B (MOCKTAIL_USE_BIONIC_LINKER=1): delegates to mcpelauncher-linker
// which loads Bionic-ABI ELF files without Glibc version conflicts.
// Without the Bionic linker, loading falls back to host dlopen/dlsym.
#include "linker/linker.h"
@@ -50,16 +46,13 @@ namespace linker {
namespace {
// Global alias → handle map maintained across all LoadLibrary calls.
std::unordered_map<std::string, LibraryHandle> g_symbol_table;
// New registrations retain symbol ownership per Android SONAME. The legacy
// global map remains isolated below until src/main.cc is migrated.
// Synthetic exports retain ownership per Android SONAME.
std::unordered_map<std::string, SymbolMap> g_synthetic_symbols;
std::unordered_map<std::string, LibraryHandle> g_synthetic_libraries;
std::unordered_set<LibraryHandle> g_synthetic_handles;
// Compatibility symbol map used only by RegisterSymbol/LoadLibrary.
SymbolMap g_legacy_bionic_symbols;
// Keep string memory alive for Bionic linker sonames to avoid dangling pointers.
@@ -121,11 +114,8 @@ int BionicDlcloseBoundary(void* handle) {
char* BionicDlerrorBoundary() { return ::linker::dlerror(); }
#endif
// The legacy startup path still discovers most imports in one untyped map.
// Bionic libc APIs with host-incompatible signatures or integer values must
// have exactly one owner, synthetic libc.so. Re-exporting those host functions
// from an earlier DT_NEEDED SONAME lets the guest bind to glibc before it
// reaches libc.so.
// Host-incompatible Bionic APIs belong only to synthetic libc.so. Exporting
// them from an earlier dependency could bind the guest to glibc instead.
bool IsBionicLibcOnlySymbol(std::string_view symbol) {
static constexpr std::string_view kStdioSymbols[] = {
"__fread_chk", "__fwrite_chk", "__sF", "clearerr",
@@ -189,8 +179,6 @@ void RemoveHandleFromRegistry(LibraryHandle handle) {
} // namespace
// Public API
void RegisterSymbol(const std::string& name, void* addr) {
std::lock_guard<std::mutex> lock(g_registry_mutex);
g_legacy_bionic_symbols[name] = addr;
@@ -351,9 +339,8 @@ void RegisterBionicHostLibcRuntimeForLibc() {
RegisterSyntheticSymbol(
"libc.so", "gethostbyname",
reinterpret_cast<void*>(mocktail_bionic_gethostbyname));
// The preserved startup path publishes older DNS wrappers through its
// legacy global map. Replace those addresses before libc.so snapshots that
// map so every supported payload crosses the upload deny boundary.
// Replace legacy DNS wrappers before libc.so snapshots the global map, so
// every payload crosses the upload deny boundary.
RegisterSymbol("getaddrinfo",
reinterpret_cast<void*>(mocktail_bionic_getaddrinfo));
RegisterSymbol("freeaddrinfo",
@@ -375,9 +362,8 @@ void RegisterBionicHostLibcRuntimeForLibc() {
reinterpret_cast<void*>(mocktail_bionic_mallinfo));
RegisterSyntheticSymbol("libc.so", "strerror_r",
reinterpret_cast<void*>(mocktail_bionic_strerror_r));
// The preserved startup path scans libroblox imports through the legacy
// global map before relocation. Publish the same address there so it
// cannot auto-register glibc's GNU char*-returning strerror_r instead.
// Publish this before relocation to prevent binding glibc's incompatible
// GNU char*-returning strerror_r.
RegisterSymbol("strerror_r",
reinterpret_cast<void*>(mocktail_bionic_strerror_r));
RegisterSyntheticSymbol("libc.so", "strtoll_l",
@@ -643,9 +629,7 @@ LibraryHandle LoadLibrary(const std::string& real_path,
return OpenAndroidLibrary(real_path, host_alias);
}
// The legacy composition already reaches this per-SONAME boundary for
// every Android dependency. Register host adapters here so libc.so owns the
// guest ABI without adding calls to the preserved legacy startup TU.
// Register adapters here so libc.so owns the guest ABI per SONAME.
if (real_path == "libc.so") {
RegisterBionicAtForkRuntimeForLibc();
RegisterBionicHostLibcRuntimeForLibc();
+68 -59
View File
@@ -6,11 +6,8 @@
//
// http://www.apache.org/licenses/LICENSE-2.0
// Mocktail's composition root stays intentionally small. During the
// upstream-first migration it delegates to the isolated legacy runtime; new
// platform, JNI, loader, and service modules are linked as explicit targets.
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
@@ -48,6 +45,7 @@
#include "runtime/runtime_config_bootstrap.h"
#include "runtime/runtime_config_file.h"
#include "runtime/runtime_paths.h"
#include "runtime/session_log.h"
#include "runtime/single_instance_lock.h"
#include "runtime/support_bundle.h"
#include "runtime/supported_launch_policy.h"
@@ -113,6 +111,7 @@ mocktail::Status ShutdownPlatformBridges(jnivm::VM* vm) {
} // namespace
int main(int argc, char* argv[]) {
const auto process_started_at = std::chrono::system_clock::now();
mocktail::runtime::CommandLineParseResult command_line =
mocktail::runtime::ParseCommandLine(argc, argv);
if (!command_line) {
@@ -168,6 +167,7 @@ int main(int argc, char* argv[]) {
}
mocktail::runtime::ScrubCommandLineLaunchArguments(&command_line.options,
argc, argv);
mocktail::runtime::SessionLog session_log;
mocktail::runtime::FailureSupportBundleGuard support_bundle_guard(
environment, paths,
command_line.options.mode == mocktail::runtime::CommandMode::kRun);
@@ -225,10 +225,6 @@ int main(int argc, char* argv[]) {
}
return EXIT_FAILURE;
}
if (config_bootstrap.created()) {
std::cout << " [runtime] created first-run configuration: "
<< paths.config_file() << '\n';
}
mocktail::runtime::RuntimeConfigLoadResult runtime_config =
mocktail::runtime::LoadRuntimeConfig(environment, paths.config_file());
if (!runtime_config) {
@@ -270,17 +266,33 @@ int main(int argc, char* argv[]) {
use_memory_limit
? runtime_config.config.performance().memory_limit_bytes()
: 0;
mocktail::runtime::CgroupMemoryLimitResult cgroup_limit;
if (use_memory_limit) {
cgroup_limit = mocktail::runtime::MaybeReexecWithCgroupMemoryLimit(
argc, argv, memory_limit_bytes, &cgroup_reexec_arguments);
}
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
session_log = mocktail::runtime::SessionLog::Start(
environment, paths, process_started_at);
if (session_log) {
std::cout << session_log.Header(
environment, paths,
runtime_config.config.graphics_backend_name())
<< std::flush;
} else if (session_log.attempted()) {
std::cerr << " [session] automatic logging unavailable: "
<< session_log.error() << '\n';
}
}
if (config_bootstrap.created()) {
std::cout << " [runtime] created first-run configuration: "
<< paths.config_file() << '\n';
}
if (use_memory_limit) {
const mocktail::runtime::CgroupMemoryLimitResult cgroup_limit =
mocktail::runtime::MaybeReexecWithCgroupMemoryLimit(
argc, argv, memory_limit_bytes, &cgroup_reexec_arguments);
if (cgroup_limit.active()) {
std::cout << " [memory] hard process-tree limit active: "
<< runtime_config.config.performance().memory_limit_mb
<< " MiB RAM, swap disabled\n";
} else {
std::cerr << " [memory] cgroup hard limit unavailable: "
<< cgroup_limit.detail << "; using the RSS+swap watchdog\n";
}
}
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
@@ -292,6 +304,42 @@ int main(int argc, char* argv[]) {
: "")
<< '\n';
}
// Register before starting helper processes or loading the Android payload.
// libgamemode caches its shared
// D-Bus connection, so the first use must happen in the final game process.
const mocktail::runtime::GameModePolicy game_mode_policy =
runtime_config.config.performance().game_mode;
mocktail::runtime::GameModeSession game_mode_session =
command_line.options.mode == mocktail::runtime::CommandMode::kRun
? mocktail::runtime::GameModeSession::Start(game_mode_policy)
: mocktail::runtime::GameModeSession();
switch (game_mode_session.state()) {
case mocktail::runtime::GameModeSessionState::kActive:
std::cout << " [gamemode] performance request active\n";
break;
case mocktail::runtime::GameModeSessionState::kAlreadyActive:
std::cout << " [gamemode] already active for this process\n";
break;
case mocktail::runtime::GameModeSessionState::kUnavailable:
case mocktail::runtime::GameModeSessionState::kRequestFailed:
if (game_mode_policy == mocktail::runtime::GameModePolicy::kOn) {
std::cerr << " [gamemode] requested but unavailable: "
<< game_mode_session.detail()
<< "; continuing without GameMode\n";
} else {
std::cout << " [gamemode] unavailable; continuing normally\n";
}
break;
case mocktail::runtime::GameModeSessionState::kDisabled:
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
std::cout << " [gamemode] disabled by runtime policy\n";
}
break;
case mocktail::runtime::GameModeSessionState::kStopped:
case mocktail::runtime::GameModeSessionState::kStopFailed:
break;
}
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
failure_dialog = mocktail::runtime::FailureDialogMonitor::Start(
environment,
@@ -418,10 +466,8 @@ int main(int argc, char* argv[]) {
<< active_build_id.error << '\n';
return EXIT_FAILURE;
}
// An old local probation receipt can outlive the point where the
// exact Build ID becomes shipped support. Built-in profiles are
// immutable and must then use the repository manifest instead of
// attempting an external override.
// Built-in profiles supersede stale probation receipts and never
// use external overrides.
use_external_profile = mocktail::compat::FindHostAbiProfile(
active_build_id.build_id) == nullptr;
}
@@ -721,45 +767,10 @@ int main(int argc, char* argv[]) {
dependencies = mocktail::legacy::RuntimeDependencies(
std::move(composition), &ShutdownPlatformBridges);
}
const mocktail::runtime::GameModePolicy game_mode_policy =
runtime_config.config.performance().game_mode;
mocktail::runtime::GameModeSession game_mode_session =
command_line.options.mode == mocktail::runtime::CommandMode::kRun
? mocktail::runtime::GameModeSession::Start(game_mode_policy)
: mocktail::runtime::GameModeSession();
switch (game_mode_session.state()) {
case mocktail::runtime::GameModeSessionState::kActive:
std::cout << " [gamemode] performance request active\n";
break;
case mocktail::runtime::GameModeSessionState::kAlreadyActive:
std::cout << " [gamemode] already active for this process\n";
break;
case mocktail::runtime::GameModeSessionState::kUnavailable:
case mocktail::runtime::GameModeSessionState::kRequestFailed:
if (game_mode_policy == mocktail::runtime::GameModePolicy::kOn) {
std::cerr << " [gamemode] requested but unavailable: "
<< game_mode_session.detail()
<< "; continuing without GameMode\n";
} else {
std::cout << " [gamemode] unavailable; continuing normally\n";
}
break;
case mocktail::runtime::GameModeSessionState::kDisabled:
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
std::cout << " [gamemode] disabled by runtime policy\n";
}
break;
case mocktail::runtime::GameModeSessionState::kStopped:
case mocktail::runtime::GameModeSessionState::kStopFailed:
break;
}
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun &&
!isolated_canary) {
// Start accepting browser requests only after cgroup re-exec, payload
// preflight and host bridge setup have succeeded. An ACK therefore cannot
// be invalidated by a known host-side
// startup or exec boundary. Isolated canaries cannot receive browser
// launches and own no public per-user endpoint.
// Listen only after re-exec, preflight, and bridge setup, so an ACK cannot
// precede a known startup failure. Isolated canaries expose no endpoint.
mocktail::Status broker_status =
mocktail::runtime::ExternalLaunchBroker::StartOwnerAfterLockAcquired(
broker_options, &external_launch_broker.broker(),
@@ -804,10 +815,8 @@ int main(int argc, char* argv[]) {
command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
failure_dialog.MarkSuccessful();
support_bundle_guard.Disarm();
// The embedded Android payload registers process-global atexit handlers
// for workers that it does not expose for joining. The supported runtime
// reaches this boundary only after explicit lifecycle, audio, input, and
// SDL shutdown; do not re-enter guest teardown from the host C runtime.
// Guest atexit handlers target workers that cannot be joined. Host
// shutdown is already complete here, so do not re-enter guest teardown.
std::cout.flush();
std::cerr.flush();
std::_Exit(EXIT_SUCCESS);
+2 -2
View File
@@ -264,8 +264,8 @@ std::string CommandLineUsage(const std::string& program_name) {
"and\n"
<< " uses its native sign-in flow. New credentials are stored "
"privately.\n\n"
<< "Debug and experimental toggles remain available as MOCKTAIL_* env "
"vars.\n";
<< "Additional runtime options are available as MOCKTAIL_* environment "
"variables.\n";
return usage.str();
}
+918
View File
@@ -0,0 +1,918 @@
// 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
#include "runtime/discord_rpc.h"
#include <curl/curl.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <limits>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
#include "runtime/owned_pthread.h"
namespace mocktail {
namespace runtime {
namespace {
using Json = nlohmann::json;
constexpr std::size_t kMaximumDiscordFrameBytes = 1024U * 1024U;
constexpr std::size_t kMaximumRobloxApiBytes = 512U * 1024U;
constexpr std::size_t kDiscordWorkerStackBytes = 16U * 1024U * 1024U;
constexpr std::chrono::milliseconds kReconnectDelay{5000};
constexpr std::chrono::seconds kDiscordActivityRateWindow{20};
constexpr std::size_t kDiscordActivityRateLimit = 5;
constexpr std::chrono::seconds kPlaceMetadataRetryBase{10};
constexpr std::chrono::seconds kPlaceMetadataRetryMaximum{300};
constexpr char kJoinPage[] = "https://komaruworld.github.io/mocktail/join.html";
const char* PresencePhaseName(RobloxExperiencePresencePhase phase) {
switch (phase) {
case RobloxExperiencePresencePhase::kBrowsing:
return "browsing";
case RobloxExperiencePresencePhase::kJoining:
return "joining";
case RobloxExperiencePresencePhase::kPlaying:
return "playing";
}
return "unknown";
}
enum class DiscordOpcode : uint32_t {
kHandshake = 0,
kFrame = 1,
kClose = 2,
kPing = 3,
kPong = 4,
};
struct DiscordFrame {
DiscordOpcode opcode = DiscordOpcode::kClose;
std::string payload;
};
std::string TruncateUtf8(std::string value, std::size_t maximum) {
if (value.size() <= maximum) {
return value;
}
std::size_t boundary = maximum;
while (boundary != 0 &&
(static_cast<unsigned char>(value[boundary]) & 0xc0U) == 0x80U) {
--boundary;
}
value.resize(boundary);
return value;
}
std::string DumpJson(const Json& value) {
return value.dump(-1, ' ', false, Json::error_handler_t::replace);
}
std::string RenderPlaceTemplate(std::string text, std::string_view place_name) {
constexpr std::string_view placeholder = "{place_name}";
std::size_t offset = 0;
while ((offset = text.find(placeholder, offset)) != std::string::npos) {
text.replace(offset, placeholder.size(), place_name);
offset += place_name.size();
}
return TruncateUtf8(std::move(text), 128);
}
std::string PercentEncode(std::string_view value) {
constexpr char hex[] = "0123456789ABCDEF";
std::string encoded;
encoded.reserve(value.size() * 3);
for (const unsigned char byte : value) {
if ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') ||
(byte >= '0' && byte <= '9') || byte == '-' || byte == '_' ||
byte == '.' || byte == '~') {
encoded.push_back(static_cast<char>(byte));
} else {
encoded.push_back('%');
encoded.push_back(hex[byte >> 4U]);
encoded.push_back(hex[byte & 0x0fU]);
}
}
return encoded;
}
bool HasDiscordJoinTarget(const RobloxExperienceLaunchRequest& request) {
return request.place_id > 0 &&
(request.game_instance_id.empty() ||
(request.game_instance_id.size() <= 128 &&
std::none_of(request.game_instance_id.begin(),
request.game_instance_id.end(),
[](unsigned char byte) {
return byte < 0x20 || byte == 0x7f;
})));
}
bool WriteAll(int descriptor, const void* bytes, std::size_t size) {
const auto* next = static_cast<const unsigned char*>(bytes);
while (size != 0) {
const ssize_t written =
send(descriptor, next, size, MSG_NOSIGNAL | MSG_DONTWAIT);
if (written > 0) {
next += written;
size -= static_cast<std::size_t>(written);
continue;
}
if (written < 0 && errno == EINTR) {
continue;
}
if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
pollfd event{descriptor, POLLOUT, 0};
if (poll(&event, 1, 1500) > 0 && (event.revents & POLLOUT) != 0) {
continue;
}
}
return false;
}
return true;
}
bool ReadAll(int descriptor, void* bytes, std::size_t size) {
auto* next = static_cast<unsigned char*>(bytes);
while (size != 0) {
const ssize_t received = recv(descriptor, next, size, 0);
if (received > 0) {
next += received;
size -= static_cast<std::size_t>(received);
continue;
}
if (received < 0 && errno == EINTR) {
continue;
}
return false;
}
return true;
}
void EncodeLittleEndian(uint32_t value, unsigned char* destination) {
destination[0] = static_cast<unsigned char>(value & 0xffU);
destination[1] = static_cast<unsigned char>((value >> 8U) & 0xffU);
destination[2] = static_cast<unsigned char>((value >> 16U) & 0xffU);
destination[3] = static_cast<unsigned char>((value >> 24U) & 0xffU);
}
uint32_t DecodeLittleEndian(const unsigned char* source) {
return static_cast<uint32_t>(source[0]) |
(static_cast<uint32_t>(source[1]) << 8U) |
(static_cast<uint32_t>(source[2]) << 16U) |
(static_cast<uint32_t>(source[3]) << 24U);
}
bool WriteFrame(int descriptor, DiscordOpcode opcode,
std::string_view payload) {
if (payload.size() > kMaximumDiscordFrameBytes ||
payload.size() > std::numeric_limits<uint32_t>::max()) {
return false;
}
std::array<unsigned char, 8> header{};
EncodeLittleEndian(static_cast<uint32_t>(opcode), header.data());
EncodeLittleEndian(static_cast<uint32_t>(payload.size()), header.data() + 4);
return WriteAll(descriptor, header.data(), header.size()) &&
WriteAll(descriptor, payload.data(), payload.size());
}
bool ReadFrame(int descriptor, DiscordFrame* frame) {
std::array<unsigned char, 8> header{};
if (frame == nullptr || !ReadAll(descriptor, header.data(), header.size())) {
return false;
}
const uint32_t length = DecodeLittleEndian(header.data() + 4);
if (length > kMaximumDiscordFrameBytes) {
return false;
}
frame->opcode = static_cast<DiscordOpcode>(DecodeLittleEndian(header.data()));
frame->payload.resize(length);
return length == 0 ||
ReadAll(descriptor, frame->payload.data(), frame->payload.size());
}
void LogDiscordRpcError(const DiscordFrame& frame) {
if (frame.opcode != DiscordOpcode::kFrame) {
return;
}
const Json response = Json::parse(frame.payload, nullptr, false);
if (!response.is_object()) {
return;
}
const auto event = response.find("evt");
if (event == response.end() || !event->is_string() ||
event->get_ref<const std::string&>() != "ERROR") {
return;
}
int64_t code = 0;
std::string message = "Discord rejected an RPC request";
const auto data = response.find("data");
if (data != response.end() && data->is_object()) {
const auto code_value = data->find("code");
if (code_value != data->end() && code_value->is_number_integer()) {
code = code_value->get<int64_t>();
}
const auto message_value = data->find("message");
if (message_value != data->end() && message_value->is_string()) {
message = TruncateUtf8(message_value->get<std::string>(), 256);
for (char& byte : message) {
if (static_cast<unsigned char>(byte) < 0x20U || byte == 0x7f) {
byte = ' ';
}
}
}
}
std::fprintf(stderr, " [discord-rpc] request rejected code=%lld: %s\n",
static_cast<long long>(code), message.c_str());
}
std::vector<std::string> DiscordSocketDirectories() {
std::vector<std::string> directories;
const auto append = [&directories](const char* path) {
if (path != nullptr && path[0] != '\0') {
directories.emplace_back(path);
}
};
const char* runtime = std::getenv("XDG_RUNTIME_DIR");
append(runtime);
if (runtime != nullptr && runtime[0] != '\0') {
directories.emplace_back(std::string(runtime) +
"/app/com.discordapp.Discord");
directories.emplace_back(std::string(runtime) +
"/.flatpak/com.discordapp.Discord/xdg-run");
directories.emplace_back(std::string(runtime) + "/snap.discord");
}
append(std::getenv("TMPDIR"));
directories.emplace_back("/tmp");
directories.emplace_back("/run/user/" + std::to_string(getuid()));
std::unordered_set<std::string> unique;
std::vector<std::string> result;
for (std::string& directory : directories) {
if (unique.insert(directory).second) {
result.push_back(std::move(directory));
}
}
return result;
}
int ConnectDiscord() {
for (const std::string& directory : DiscordSocketDirectories()) {
for (int index = 0; index < 10; ++index) {
const std::string path =
directory + "/discord-ipc-" + std::to_string(index);
sockaddr_un address{};
address.sun_family = AF_UNIX;
if (path.size() >= sizeof(address.sun_path)) {
continue;
}
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
const int descriptor = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (descriptor < 0) {
continue;
}
timeval timeout{2, 0};
(void)setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout,
sizeof(timeout));
(void)setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout,
sizeof(timeout));
if (connect(descriptor, reinterpret_cast<const sockaddr*>(&address),
sizeof(address)) == 0) {
return descriptor;
}
(void)close(descriptor);
}
}
return -1;
}
bool HandshakeDiscord(int descriptor, std::string_view application_id) {
Json handshake = {{"v", 1}, {"client_id", application_id}};
if (!WriteFrame(descriptor, DiscordOpcode::kHandshake, DumpJson(handshake))) {
return false;
}
for (int attempt = 0; attempt < 3; ++attempt) {
DiscordFrame frame;
if (!ReadFrame(descriptor, &frame)) {
return false;
}
if (frame.opcode == DiscordOpcode::kPing) {
if (!WriteFrame(descriptor, DiscordOpcode::kPong, frame.payload)) {
return false;
}
continue;
}
if (frame.opcode != DiscordOpcode::kFrame) {
return false;
}
const Json response = Json::parse(frame.payload, nullptr, false);
const auto event =
response.is_object() ? response.find("evt") : response.end();
return event != response.end() && event->is_string() &&
event->get_ref<const std::string&>() == "READY";
}
return false;
}
Json ActivityJson(const DiscordRpcActivity& activity) {
Json result = {
{"type", 0}, {"details", activity.details}, {"instance", true}};
if (!activity.state.empty()) {
result["state"] = activity.state;
}
if (activity.start_timestamp.has_value()) {
result["timestamps"] = {{"start", *activity.start_timestamp}};
}
if (!activity.large_image.empty()) {
result["assets"] = {{"large_image", activity.large_image}};
if (!activity.large_text.empty()) {
result["assets"]["large_text"] = activity.large_text;
}
}
if (!activity.button_label.empty() && !activity.button_url.empty()) {
result["buttons"] = Json::array(
{{{"label", activity.button_label}, {"url", activity.button_url}}});
}
return result;
}
bool SetDiscordActivity(int descriptor, const DiscordRpcActivity* activity,
uint64_t nonce) {
Json args = {{"pid", getpid()}};
args["activity"] = activity != nullptr ? ActivityJson(*activity) : Json();
const Json request = {{"cmd", "SET_ACTIVITY"},
{"args", std::move(args)},
{"nonce", std::to_string(nonce)}};
return WriteFrame(descriptor, DiscordOpcode::kFrame, DumpJson(request));
}
struct BoundedResponse {
std::string body;
bool exceeded = false;
};
std::once_flag g_curl_init_once;
CURLcode g_curl_init_status = CURLE_FAILED_INIT;
void InitializeCurl() {
g_curl_init_status = curl_global_init(CURL_GLOBAL_DEFAULT);
}
size_t WriteHttpResponse(char* data, size_t size, size_t count,
void* user_data) {
auto* response = static_cast<BoundedResponse*>(user_data);
if (response == nullptr || data == nullptr ||
(count != 0 && size > std::numeric_limits<size_t>::max() / count)) {
return 0;
}
const std::size_t bytes = size * count;
if (bytes > kMaximumRobloxApiBytes ||
response->body.size() > kMaximumRobloxApiBytes - bytes) {
response->exceeded = true;
return 0;
}
response->body.append(data, bytes);
return bytes;
}
std::optional<Json> GetRobloxJson(std::string url) {
std::call_once(g_curl_init_once, InitializeCurl);
if (g_curl_init_status != CURLE_OK) {
return std::nullopt;
}
CURL* curl = curl_easy_init();
if (curl == nullptr) {
return std::nullopt;
}
BoundedResponse response;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 2000L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 3500L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mocktail/0.1");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteHttpResponse);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
std::string proxy_url;
const char* proxy_host = std::getenv("MOCKTAIL_HTTP_PROXY_HOST");
const char* proxy_port = std::getenv("MOCKTAIL_HTTP_PROXY_PORT");
if (proxy_host != nullptr && proxy_port != nullptr) {
const std::optional<NetworkProxyConfig> proxy =
ParseNetworkProxyConfig(proxy_host, proxy_port);
if (proxy.has_value()) {
proxy_url = BuildNetworkProxyUrl(*proxy);
curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url.c_str());
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
}
}
const CURLcode status = curl_easy_perform(curl);
long http_status = 0;
if (status == CURLE_OK) {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status);
}
curl_easy_cleanup(curl);
if (status != CURLE_OK || http_status != 200 || response.exceeded) {
return std::nullopt;
}
Json parsed = Json::parse(response.body, nullptr, false);
if (parsed.is_discarded()) {
return std::nullopt;
}
return parsed;
}
struct ResolvedPlaceMetadata {
std::string name;
std::string icon_url;
};
bool IsSafeExternalImageUrl(std::string_view url) {
return url.size() <= 512 && url.substr(0, 8) == "https://" &&
std::none_of(url.begin(), url.end(), [](unsigned char byte) {
return byte < 0x20U || byte == 0x7fU;
});
}
ResolvedPlaceMetadata ResolvePlaceMetadata(int64_t place_id) {
ResolvedPlaceMetadata metadata;
if (place_id <= 0) {
return metadata;
}
const std::optional<Json> universe =
GetRobloxJson("https://apis.roblox.com/universes/v1/places/" +
std::to_string(place_id) + "/universe");
if (!universe.has_value() || !universe->contains("universeId")) {
return metadata;
}
const Json& identifier = (*universe)["universeId"];
int64_t universe_id = 0;
if (identifier.is_number_unsigned()) {
const uint64_t unsigned_id = identifier.get<uint64_t>();
if (unsigned_id >
static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
return metadata;
}
universe_id = static_cast<int64_t>(unsigned_id);
} else if (identifier.is_number_integer()) {
universe_id = identifier.get<int64_t>();
} else {
return metadata;
}
if (universe_id <= 0) {
return metadata;
}
const std::optional<Json> game =
GetRobloxJson("https://games.roblox.com/v1/games?universeIds=" +
std::to_string(universe_id));
if (game.has_value() && game->contains("data") &&
(*game)["data"].is_array() && !(*game)["data"].empty()) {
const Json& first = (*game)["data"][0];
if (first.contains("name") && first["name"].is_string()) {
metadata.name =
TruncateUtf8(first["name"].get<std::string>(), 128);
}
}
const std::optional<Json> thumbnail = GetRobloxJson(
"https://thumbnails.roblox.com/v1/games/icons?universeIds=" +
std::to_string(universe_id) +
"&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false");
if (thumbnail.has_value() && thumbnail->contains("data") &&
(*thumbnail)["data"].is_array() && !(*thumbnail)["data"].empty()) {
const Json& first = (*thumbnail)["data"][0];
if (first.contains("imageUrl") && first["imageUrl"].is_string()) {
std::string icon_url = first["imageUrl"].get<std::string>();
if (IsSafeExternalImageUrl(icon_url)) {
metadata.icon_url = std::move(icon_url);
}
}
}
return metadata;
}
std::chrono::seconds PlaceMetadataRetryDelay(unsigned failures) {
const unsigned shift = std::min(failures, 5U);
return std::min(kPlaceMetadataRetryBase * (1U << shift),
kPlaceMetadataRetryMaximum);
}
} // namespace
bool IsPublicDiscordJoin(const RobloxExperienceLaunchRequest& request) {
return HasDiscordJoinTarget(request) &&
request.reserved_server_access_code.empty() &&
request.access_code.empty() && request.link_code.empty();
}
std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request) {
if (!HasDiscordJoinTarget(request)) {
return {};
}
std::string url =
std::string(kJoinPage) + "#placeId=" + std::to_string(request.place_id);
if (!request.game_instance_id.empty()) {
url += "&gameInstanceId=" + PercentEncode(request.game_instance_id);
}
return url.size() <= 512 ? std::move(url) : std::string();
}
DiscordRpcActivity BuildDiscordRpcActivity(
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request, std::string place_name,
int64_t session_started_at, std::string place_icon_url) {
DiscordRpcActivity activity;
activity.state = TruncateUtf8(config.text.state, 128);
switch (phase) {
case RobloxExperiencePresencePhase::kBrowsing:
activity.details = TruncateUtf8(config.text.browsing, 128);
break;
case RobloxExperiencePresencePhase::kJoining:
case RobloxExperiencePresencePhase::kPlaying: {
if (place_name.empty()) {
if (phase == RobloxExperiencePresencePhase::kJoining) {
activity.details = TruncateUtf8(config.text.joining, 128);
break;
}
place_name = config.text.unknown_place;
}
if (config.show_place_name) {
activity.details = RenderPlaceTemplate(config.text.playing, place_name);
} else {
activity.details = activity.state;
activity.state.clear();
}
if (phase == RobloxExperiencePresencePhase::kPlaying &&
config.show_elapsed_time && session_started_at > 0) {
activity.start_timestamp = session_started_at;
}
if (IsSafeExternalImageUrl(place_icon_url)) {
activity.large_image = std::move(place_icon_url);
activity.large_text = TruncateUtf8(place_name, 128);
}
if (phase == RobloxExperiencePresencePhase::kPlaying &&
config.join_enabled && request != nullptr &&
(!config.public_servers_only || IsPublicDiscordJoin(*request))) {
activity.button_url = BuildDiscordJoinUrl(*request);
if (!activity.button_url.empty()) {
activity.button_label = config.join_button_label;
}
}
break;
}
}
return activity;
}
class DiscordRpcSession::Impl final {
public:
explicit Impl(DiscordRpcConfig config) : config_(std::move(config)) {}
~Impl() { Stop(); }
bool Start(std::string* detail) {
if (!config_.enabled) {
if (detail != nullptr) {
*detail = "disabled by runtime policy";
}
return true;
}
if (config_.application_id.empty()) {
if (detail != nullptr) {
*detail = "no Discord application ID is bundled";
}
return false;
}
std::call_once(g_curl_init_once, InitializeCurl);
{
std::lock_guard<std::mutex> lock(mutex_);
if (started_) {
return true;
}
phase_ = RobloxExperiencePresencePhase::kBrowsing;
request_ = {};
revision_ = 1;
stopping_ = false;
}
const int error =
worker_.Start(&Impl::RunWorker, this, kDiscordWorkerStackBytes);
if (error != 0) {
if (detail != nullptr) {
*detail =
"could not start Discord RPC worker: " + std::to_string(error);
}
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
started_ = true;
return true;
}
void Stop() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!started_ && !worker_.joinable()) {
return;
}
stopping_ = true;
condition_.notify_all();
}
if (worker_.joinable()) {
(void)worker_.WaitFor(-1, 1);
}
std::lock_guard<std::mutex> lock(mutex_);
started_ = false;
}
void Update(RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request) {
std::lock_guard<std::mutex> lock(mutex_);
if (!started_ || stopping_) {
return;
}
phase_ = phase;
request_ = {};
if (request != nullptr) {
// Never pass access codes or canonical launch JSON to the Discord worker.
request_.place_id = request->place_id;
request_.game_instance_id = request->game_instance_id;
if (!request->reserved_server_access_code.empty()) {
request_.reserved_server_access_code = "present";
}
if (!request->access_code.empty()) {
request_.access_code = "present";
}
if (!request->link_code.empty()) {
request_.link_code = "present";
}
}
if (phase == RobloxExperiencePresencePhase::kPlaying) {
session_started_at_ =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
} else {
session_started_at_ = 0;
}
++revision_;
std::fprintf(stderr,
" [discord-rpc] queued phase=%s revision=%llu "
"place_id=%lld\n",
PresencePhaseName(phase_),
static_cast<unsigned long long>(revision_),
static_cast<long long>(request_.place_id));
condition_.notify_all();
}
private:
struct Desired {
RobloxExperiencePresencePhase phase =
RobloxExperiencePresencePhase::kBrowsing;
RobloxExperienceLaunchRequest request;
int64_t started_at = 0;
uint64_t revision = 0;
};
struct CachedPlaceMetadata {
std::string name;
std::string icon_url;
std::chrono::steady_clock::time_point next_attempt{};
unsigned failures = 0;
};
static void* RunWorker(void* context) {
static_cast<Impl*>(context)->Run();
return nullptr;
}
bool IsStopping() {
std::lock_guard<std::mutex> lock(mutex_);
return stopping_;
}
Desired Snapshot() {
std::lock_guard<std::mutex> lock(mutex_);
return {phase_, request_, session_started_at_, revision_};
}
bool WaitForChange(uint64_t revision, std::chrono::milliseconds duration) {
std::unique_lock<std::mutex> lock(mutex_);
condition_.wait_for(lock, duration, [this, revision]() {
return stopping_ || revision_ != revision;
});
return !stopping_;
}
void Run() {
int descriptor = -1;
uint64_t published_revision = 0;
uint64_t nonce = 1;
std::deque<std::chrono::steady_clock::time_point> activity_updates;
while (!IsStopping()) {
Desired desired = Snapshot();
std::string place_name;
std::string place_icon_url;
bool resolve_metadata_after_publish = false;
if ((desired.phase == RobloxExperiencePresencePhase::kJoining ||
desired.phase == RobloxExperiencePresencePhase::kPlaying) &&
desired.request.place_id > 0) {
CachedPlaceMetadata& cached =
place_metadata_[desired.request.place_id];
place_name = cached.name;
place_icon_url = cached.icon_url;
if ((cached.name.empty() || cached.icon_url.empty()) &&
std::chrono::steady_clock::now() >= cached.next_attempt) {
resolve_metadata_after_publish = true;
}
}
if (descriptor < 0) {
descriptor = ConnectDiscord();
if (descriptor < 0 ||
!HandshakeDiscord(descriptor, config_.application_id)) {
if (descriptor >= 0) {
(void)close(descriptor);
descriptor = -1;
}
if (!WaitForChange(desired.revision, kReconnectDelay)) {
break;
}
continue;
}
std::fprintf(stderr, " [discord-rpc] connected to Discord Desktop\n");
published_revision = 0;
}
const bool wait_for_place_metadata =
(desired.phase == RobloxExperiencePresencePhase::kJoining ||
desired.phase == RobloxExperiencePresencePhase::kPlaying) &&
desired.request.place_id > 0 && place_name.empty();
if (published_revision != desired.revision &&
!wait_for_place_metadata) {
const auto now = std::chrono::steady_clock::now();
while (!activity_updates.empty() &&
now - activity_updates.front() >= kDiscordActivityRateWindow) {
activity_updates.pop_front();
}
if (activity_updates.size() >= kDiscordActivityRateLimit) {
const auto delay =
std::chrono::duration_cast<std::chrono::milliseconds>(
activity_updates.front() + kDiscordActivityRateWindow - now);
(void)WaitForChange(desired.revision, delay);
continue;
}
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config_, desired.phase,
desired.phase == RobloxExperiencePresencePhase::kBrowsing
? nullptr
: &desired.request,
std::move(place_name), desired.started_at,
std::move(place_icon_url));
if (!SetDiscordActivity(descriptor, &activity, nonce++)) {
(void)close(descriptor);
descriptor = -1;
continue;
}
std::fprintf(stderr,
" [discord-rpc] sent phase=%s revision=%llu\n",
PresencePhaseName(desired.phase),
static_cast<unsigned long long>(desired.revision));
activity_updates.push_back(std::chrono::steady_clock::now());
published_revision = desired.revision;
}
if (resolve_metadata_after_publish) {
const ResolvedPlaceMetadata resolved =
ResolvePlaceMetadata(desired.request.place_id);
CachedPlaceMetadata& cached =
place_metadata_[desired.request.place_id];
bool changed = false;
if (!resolved.name.empty() && resolved.name != cached.name) {
cached.name = resolved.name;
changed = true;
}
if (!resolved.icon_url.empty() &&
resolved.icon_url != cached.icon_url) {
cached.icon_url = resolved.icon_url;
changed = true;
}
if (cached.name.empty() || cached.icon_url.empty()) {
const auto delay = PlaceMetadataRetryDelay(cached.failures++);
cached.next_attempt = std::chrono::steady_clock::now() + delay;
std::fprintf(stderr,
" [discord-rpc] place metadata incomplete; retry in "
"%llds place_id=%lld\n",
static_cast<long long>(delay.count()),
static_cast<long long>(desired.request.place_id));
} else {
cached.failures = 0;
cached.next_attempt = {};
}
if (Snapshot().revision != desired.revision) {
continue;
}
if (changed &&
(desired.phase == RobloxExperiencePresencePhase::kJoining ||
desired.phase == RobloxExperiencePresencePhase::kPlaying)) {
// Publish cached metadata through the normal rate limiter.
published_revision = 0;
continue;
}
}
pollfd event{descriptor, POLLIN, 0};
const int ready = poll(&event, 1, 500);
if (ready < 0 && errno == EINTR) {
continue;
}
if (ready < 0 || (ready > 0 && (event.revents &
(POLLERR | POLLHUP | POLLNVAL)) != 0)) {
(void)close(descriptor);
descriptor = -1;
continue;
}
if (ready > 0 && (event.revents & POLLIN) != 0) {
DiscordFrame frame;
if (!ReadFrame(descriptor, &frame)) {
(void)close(descriptor);
descriptor = -1;
} else if (frame.opcode == DiscordOpcode::kPing) {
if (!WriteFrame(descriptor, DiscordOpcode::kPong, frame.payload)) {
(void)close(descriptor);
descriptor = -1;
}
} else if (frame.opcode == DiscordOpcode::kClose) {
(void)close(descriptor);
descriptor = -1;
} else {
LogDiscordRpcError(frame);
}
}
}
if (descriptor >= 0) {
(void)SetDiscordActivity(descriptor, nullptr, nonce);
(void)close(descriptor);
}
}
const DiscordRpcConfig config_;
std::mutex mutex_;
std::condition_variable condition_;
OwnedPthread worker_;
RobloxExperiencePresencePhase phase_ =
RobloxExperiencePresencePhase::kBrowsing;
RobloxExperienceLaunchRequest request_;
std::unordered_map<int64_t, CachedPlaceMetadata> place_metadata_;
int64_t session_started_at_ = 0;
uint64_t revision_ = 0;
bool started_ = false;
bool stopping_ = false;
};
DiscordRpcSession::DiscordRpcSession(DiscordRpcConfig config)
: impl_(std::make_unique<Impl>(std::move(config))) {}
DiscordRpcSession::~DiscordRpcSession() { Stop(); }
bool DiscordRpcSession::Start(std::string* detail) {
return impl_->Start(detail);
}
void DiscordRpcSession::Stop() { impl_->Stop(); }
RobloxExperiencePresenceObserver DiscordRpcSession::observer() {
return {this, &DiscordRpcSession::Notify};
}
void DiscordRpcSession::Notify(void* context,
RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request) {
if (context != nullptr) {
static_cast<DiscordRpcSession*>(context)->impl_->Update(phase, request);
}
}
} // namespace runtime
} // namespace mocktail
+18 -2
View File
@@ -15,6 +15,9 @@
#include "runtime/game_mode.h"
#include <dlfcn.h>
#if defined(__GLIBC__)
#include <link.h>
#endif
#include <cstring>
#include <string>
@@ -54,6 +57,19 @@ std::string ClientError(const GameModeClientApi& api,
return std::string(fallback);
}
void* OpenClientLibrary(const char* name) {
#if defined(__GLIBC__)
// Mocktail intentionally exports Bionic compatibility symbols for the
// Android payload. A normal dlopen() lets host libraries such as libdbus
// bind to those ABI-compatible-looking exports instead of glibc, which can
// corrupt the GameMode portal exchange. A new link-map namespace keeps the
// host client and all of its dependencies on the host ABI.
return dlmopen(LM_ID_NEWLM, name, RTLD_NOW | RTLD_LOCAL);
#else
return dlopen(name, RTLD_NOW | RTLD_LOCAL);
#endif
}
} // namespace
bool ParseGameModePolicy(std::string_view value, GameModePolicy* policy) {
@@ -124,9 +140,9 @@ GameModeSession GameModeSession::Start(GameModePolicy policy) {
return StartBound(policy, {}, nullptr);
}
void* library = dlopen("libgamemode.so.0", RTLD_NOW | RTLD_LOCAL);
void* library = OpenClientLibrary("libgamemode.so.0");
if (library == nullptr) {
library = dlopen("libgamemode.so", RTLD_NOW | RTLD_LOCAL);
library = OpenClientLibrary("libgamemode.so");
}
if (library == nullptr) {
GameModeSession session;
+4 -162
View File
@@ -15,7 +15,6 @@
#include "runtime/memory_limit.h"
#include <fcntl.h>
#include <limits.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
@@ -27,9 +26,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
namespace mocktail {
namespace runtime {
@@ -38,7 +35,6 @@ namespace {
constexpr std::uint64_t kBytesPerKibibyte = 1024U;
constexpr std::uint64_t kBytesPerMebibyte = 1024U * 1024U;
constexpr int kWatchdogPollMilliseconds = 100;
constexpr char kCgroupMarker[] = "MOCKTAIL_MEMORY_CGROUP_LIMIT_BYTES";
void WriteAllBestEffort(int descriptor, std::string_view bytes) {
std::size_t offset = 0;
@@ -115,90 +111,6 @@ ProcessMemoryUsage ReadCurrentProcessMemory() {
std::string_view(buffer.data(), static_cast<std::size_t>(total)));
}
bool ReadTextFile(const std::string& path, std::string* value) {
std::ifstream input(path);
if (!input) {
return false;
}
return static_cast<bool>(input >> *value);
}
bool ReadUnifiedCgroupPath(std::string* path) {
std::ifstream input("/proc/self/cgroup");
std::string line;
while (std::getline(input, line)) {
if (line.rfind("0::", 0) == 0) {
*path = line.substr(3);
return !path->empty() && path->front() == '/';
}
}
return false;
}
bool ActiveCgroupMatches(std::uint64_t expected_bytes,
std::string* detail) {
std::string cgroup_path;
if (!ReadUnifiedCgroupPath(&cgroup_path)) {
*detail = "cannot resolve the current unified cgroup";
return false;
}
const std::string root = "/sys/fs/cgroup" + cgroup_path;
std::string memory_max;
std::string swap_max;
if (!ReadTextFile(root + "/memory.max", &memory_max) ||
!ReadTextFile(root + "/memory.swap.max", &swap_max)) {
*detail = "cannot read current cgroup memory limits";
return false;
}
std::uint64_t configured_bytes = 0;
const std::from_chars_result parsed = std::from_chars(
memory_max.data(), memory_max.data() + memory_max.size(),
configured_bytes);
if (parsed.ec != std::errc() ||
parsed.ptr != memory_max.data() + memory_max.size() ||
configured_bytes > expected_bytes) {
*detail = "current cgroup memory.max does not match the requested limit";
return false;
}
if (swap_max != "0") {
*detail = "current cgroup still permits swap";
return false;
}
return true;
}
std::string CurrentExecutable(char* const argv[]) {
std::array<char, PATH_MAX + 1> executable{};
const ssize_t length =
readlink("/proc/self/exe", executable.data(), PATH_MAX);
if (length > 0) {
executable[static_cast<std::size_t>(length)] = '\0';
return executable.data();
}
return argv != nullptr && argv[0] != nullptr ? argv[0] : std::string();
}
bool SystemdUserManagerAvailable(std::string* detail) {
if (access("/sys/fs/cgroup/cgroup.controllers", R_OK) != 0) {
*detail = "cgroup v2 is not mounted";
return false;
}
if (access("/usr/bin/systemd-run", X_OK) != 0) {
*detail = "systemd-run is not installed";
return false;
}
const char* runtime_directory = getenv("XDG_RUNTIME_DIR");
const std::string user_runtime =
runtime_directory != nullptr && runtime_directory[0] != '\0'
? runtime_directory
: "/run/user/" + std::to_string(geteuid());
if (access((user_runtime + "/systemd/private").c_str(), F_OK) != 0) {
*detail = "systemd user manager is not running";
return false;
}
return true;
}
} // namespace
ProcessMemoryUsage ParseProcessStatusMemory(std::string_view status) {
@@ -221,84 +133,14 @@ CgroupMemoryLimitResult MaybeReexecWithCgroupMemoryLimit(
int argc, char* const argv[], std::uint64_t limit_bytes,
const std::vector<std::string>* reexec_arguments) {
CgroupMemoryLimitResult result;
(void)argc;
(void)argv;
(void)reexec_arguments;
if (limit_bytes == 0) {
result.detail = "memory limit is disabled";
return result;
}
const char* marker = getenv(kCgroupMarker);
if (marker != nullptr) {
std::uint64_t marked_bytes = 0;
const std::string_view marker_value(marker);
const std::from_chars_result parsed = std::from_chars(
marker_value.data(), marker_value.data() + marker_value.size(),
marked_bytes);
if (parsed.ec == std::errc() &&
parsed.ptr == marker_value.data() + marker_value.size() &&
marked_bytes == limit_bytes &&
ActiveCgroupMatches(limit_bytes, &result.detail)) {
result.status = CgroupMemoryLimitStatus::kActive;
result.detail = "cgroup v2 MemoryMax is active and swap is disabled";
} else if (result.detail.empty()) {
result.detail = "cgroup activation marker is invalid";
}
return result;
}
if (!SystemdUserManagerAvailable(&result.detail)) {
return result;
}
const std::string executable = CurrentExecutable(argv);
if (executable.empty()) {
result.status = CgroupMemoryLimitStatus::kExecFailed;
result.detail = "cannot resolve the Mocktail executable";
return result;
}
const std::string limit = std::to_string(limit_bytes);
std::vector<std::string> arguments;
arguments.reserve((reexec_arguments != nullptr
? reexec_arguments->size()
: static_cast<std::size_t>(argc)) +
10U);
arguments.emplace_back("/usr/bin/systemd-run");
arguments.emplace_back("--user");
arguments.emplace_back("--scope");
arguments.emplace_back("--quiet");
arguments.emplace_back("--same-dir");
arguments.emplace_back("--expand-environment=no");
arguments.emplace_back("--property=MemoryMax=" + limit);
arguments.emplace_back("--property=MemorySwapMax=0");
arguments.emplace_back("--");
arguments.push_back(executable);
if (reexec_arguments != nullptr) {
arguments.insert(arguments.end(), reexec_arguments->begin(),
reexec_arguments->end());
} else {
for (int index = 1; index < argc; ++index) {
arguments.emplace_back(argv[index]);
}
}
std::vector<char*> exec_arguments;
exec_arguments.reserve(arguments.size() + 1U);
for (std::string& argument : arguments) {
exec_arguments.push_back(argument.data());
}
exec_arguments.push_back(nullptr);
if (setenv(kCgroupMarker, limit.c_str(), 1) != 0) {
result.status = CgroupMemoryLimitStatus::kExecFailed;
result.detail = std::string("cannot mark cgroup re-exec: ") +
std::strerror(errno);
return result;
}
(void)fflush(nullptr);
execv(arguments.front().c_str(), exec_arguments.data());
const int exec_error = errno;
(void)unsetenv(kCgroupMarker);
result.status = CgroupMemoryLimitStatus::kExecFailed;
result.detail = std::string("cannot execute systemd-run: ") +
std::strerror(exec_error);
result.detail = "automatic cgroup scopes are disabled";
return result;
}
+97 -48
View File
@@ -33,10 +33,7 @@ namespace runtime {
namespace {
constexpr size_t kMaxPendingLaunchRequests = 8;
// Loading libroblox contributes a large static-TLS image. A one-megabyte
// pthread stack is accepted in unit tests before the guest is loaded, but
// pthread_create returns EINVAL in production once that TLS image is present.
// Keep this above the runtime's proven 16 MiB guest-thread floor.
// libroblox's static TLS requires the proven 16 MiB guest-thread stack floor.
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000};
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
@@ -56,8 +53,7 @@ Status FailedPrecondition(std::string message) {
GameSurface SnapshotProductionSurface(void*) {
const window::WindowSurfaceSnapshot snapshot =
window::GetWindowSurfaceSnapshot();
return {snapshot.generation,
snapshot.available ? snapshot.native_window : 0,
return {snapshot.generation, snapshot.available ? snapshot.native_window : 0,
snapshot.available ? snapshot.width : 0,
snapshot.available ? snapshot.height : 0};
}
@@ -278,7 +274,8 @@ RobloxExperienceComposition::RobloxExperienceComposition(
RobloxFreshLaunchPresentBoundary present_boundary,
RobloxGameSurfaceJniConfig surface_config,
const SecureRobloxCredential* initial_web_view_credential,
RobloxExperienceSurfaceProvider surface_provider)
RobloxExperienceSurfaceProvider surface_provider,
RobloxExperiencePresenceObserver presence_observer)
: environment_(environment),
message_bus_symbols_(message_bus_symbols),
web_view_symbols_(web_view_symbols),
@@ -293,6 +290,7 @@ RobloxExperienceComposition::RobloxExperienceComposition(
? surface_provider
: RobloxExperienceSurfaceProvider{nullptr,
&SnapshotProductionSurface}),
presence_observer_(presence_observer),
web_surface_exit_target_(std::make_shared<WebSurfaceExitTarget>()),
lifecycle_target_(std::make_shared<LifecycleTarget>()) {
web_surface_exit_target_->composition = this;
@@ -426,7 +424,9 @@ Status RobloxExperienceComposition::OnLuaAppReady(
}
auto controller = std::make_shared<RobloxFreshGameLaunchController>(
environment_, game_symbols_, present_boundary_, surface_config_);
environment_, game_symbols_, present_boundary_, surface_config_,
RobloxGamePresentedObserver{this,
&RobloxExperienceComposition::GamePresented});
RobloxExperienceMessageBusObjects bridge_objects;
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -509,9 +509,7 @@ Status RobloxExperienceComposition::OpenWebSurface(
}
}
// Opening and configuring the singleton is one serialized transaction.
// Optional fields are normalized before this point, so presentation state
// from the previous route can never leak into the new page.
// Serialize setup so presentation state cannot leak between routes.
if (!current->SetTitle(presentation.title) ||
!current->SetVisible(presentation.visible) ||
!current->SetBackNavigationDisabled(
@@ -623,9 +621,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
presentation.visible = request.is_visible.value_or(true);
presentation.show_domain_as_title =
request.show_domain_as_title.value_or(false);
// The host helper has no Android toolbar. Mapping a hidden Roblox back
// button to disabled host back navigation preserves the protocol's only
// actionable navigation policy without inventing a replacement header.
// The host helper has no toolbar, so a hidden Roblox back button disables
// host back navigation too.
presentation.back_navigation_disabled =
request.back_button_visible.has_value() && !*request.back_button_visible;
return composition->OpenWebSurface(request.url, "webview",
@@ -799,11 +796,8 @@ Status RobloxExperienceComposition::RouteWebSurfaceEvent(
RobloxWebViewBridge* web_view_bridge) {
switch (event.type) {
case WebViewHelperEventType::kExecuteRoblox:
// EnableAndroidWebViewService4 installs WebViewProtocol's listener on
// the shared gi.a fragment regardless of which protocol opened it. The
// MemStorage callback in gi.a is only the APK's disabled legacy
// fallback. Keeping the raw command intact is required for
// RequestGameJob fields such as instanceId and joinAttemptId.
// WebViewProtocol listens on the shared gi.a fragment. Preserve the raw
// command, including RequestGameJob instance and attempt IDs.
if (route == WebSurfaceRoute::kNone) {
return FailedPrecondition(
"WebView helper event has no active logical route");
@@ -839,9 +833,7 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
logical_generation == 0 ||
logical_generation != web_surface_logical_generation_ ||
web_surface_route_ == WebSurfaceRoute::kNone) {
// A close, helper replacement, or logical page reuse invalidates every
// event already buffered for the old surface. This is normal teardown,
// not a platform delivery failure.
// Closing or replacing the helper invalidates buffered surface events.
return Status::Ok();
}
route = web_surface_route_;
@@ -931,11 +923,10 @@ Status RobloxExperienceComposition::PromoteAuthenticatedSession() {
return Status::Ok();
}
SecurelyClearString(&credential);
const GameSurface surface = surface_provider_.snapshot(
surface_provider_.context);
const GameSurface surface =
surface_provider_.snapshot(surface_provider_.context);
RobloxLuaAppExperienceReadiness readiness;
readiness.principal.kind =
GameSessionPrincipalKind::kAuthenticated;
readiness.principal.kind = GameSessionPrincipalKind::kAuthenticated;
readiness.principal.generation = 1;
readiness.principal.principal_id = std::to_string(identity.user_id);
readiness.principal.base_url = kRobloxBaseUrl;
@@ -952,8 +943,7 @@ Status RobloxExperienceComposition::PromoteAuthenticatedSession() {
if (consumes_window_surface_events_) {
window::WindowSurfaceEvent stale_event;
while (window::PollWindowSurfaceEvent(&stale_event)) {
// Readiness already captured the latest snapshot. Older events were
// queued before a GAME consumer existed and must not replay stale state.
// Readiness already captured the latest snapshot; discard older events.
}
}
vm->SetRobloxExperienceLifecycleCallbacks(
@@ -1070,8 +1060,7 @@ Status RobloxExperienceComposition::Dispatch(
}
if (!request.canonical_json.empty()) {
const bool active_game_matches =
game_active_ &&
request.canonical_json == active_game_canonical_json_;
game_active_ && request.canonical_json == active_game_canonical_json_;
const bool active_launch_matches =
active_launch_ != nullptr &&
request.canonical_json == active_launch_->request.canonical_json;
@@ -1111,9 +1100,8 @@ Status RobloxExperienceComposition::DrainExternalLaunchRequests() {
Status status = DrainActiveExternalLaunchRequests(
ExternalLaunchSink{this, &RobloxExperienceComposition::DispatchLaunch},
available);
// The MessageBus can fill the composition queue after the capacity snapshot
// above. Keep the external request at the broker's front and retry on the
// next drain instead of turning ordinary backpressure into runtime failure.
// A MessageBus race can fill the queue; leave the request at the broker's
// front and retry on the next drain.
return status.code() == StatusCode::kUnavailable ? Status::Ok() : status;
}
@@ -1143,9 +1131,16 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
: std::string();
active_launch_.reset();
}
if (!completed_result.ok()) pending_launch_requests_.clear();
if (!completed_result.ok()) {
pending_launch_requests_.clear();
presence_request_.reset();
playing_presence_published_ = false;
}
}
if (!completed_result.ok()) {
NotifyPresence(RobloxExperiencePresencePhase::kBrowsing, nullptr);
return completed_result;
}
if (!completed_result.ok()) return completed_result;
}
std::shared_ptr<RobloxFreshGameLaunchController> returned_controller;
@@ -1153,29 +1148,34 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
std::lock_guard<std::mutex> lock(mutex_);
if (lua_app_return_pending_ && !launch_in_progress_ &&
active_launch_ == nullptr) {
// Consume before invoking the controller so a concurrent callback sets
// a new pending observation instead of being overwritten on return.
// Consume first so a concurrent callback can set a new observation.
lua_app_return_pending_ = false;
returned_controller = controller_;
}
}
if (returned_controller != nullptr) {
const Status observed = returned_controller->ObserveLuaAppReturn();
std::lock_guard<std::mutex> lock(mutex_);
if (!observed.ok()) {
lua_app_return_pending_ = true;
pending_launch_requests_.clear();
return observed;
{
std::lock_guard<std::mutex> lock(mutex_);
if (!observed.ok()) {
lua_app_return_pending_ = true;
pending_launch_requests_.clear();
return observed;
}
game_active_ = false;
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
controlled_switch_waiting_for_return_ = false;
}
game_active_ = false;
active_game_canonical_json_.clear();
controlled_switch_waiting_for_return_ = false;
NotifyPresence(RobloxExperiencePresencePhase::kBrowsing, nullptr);
}
Status external_launch_status = DrainExternalLaunchRequests();
if (!external_launch_status.ok()) return external_launch_status;
bool switch_active_game = false;
std::optional<RobloxExperienceLaunchRequest> joining_request;
{
std::lock_guard<std::mutex> lock(mutex_);
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
@@ -1184,9 +1184,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
if (controlled_switch_waiting_for_return_) return Status::Ok();
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
!pending_launch_requests_.empty()) {
// Prevent a stale gameDidLeave callback from being applied to the next
// controller. The queued launch starts only after the exact LuaApp
// return callback for this controlled leave has been consumed.
// Start the queued launch only after consuming this leave callback, so
// it cannot reach the next controller.
controlled_switch_waiting_for_return_ = true;
switch_active_game = true;
}
@@ -1235,8 +1234,13 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
task->context.join_request_type);
active_launch_ = std::move(task);
launch_in_progress_ = true;
presence_request_ = active_launch_->request;
playing_presence_published_ = false;
joining_request = active_launch_->request;
}
NotifyPresence(RobloxExperiencePresencePhase::kJoining, &*joining_request);
const int start_error =
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
kLaunchWorkerStackSize);
@@ -1245,9 +1249,12 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
std::lock_guard<std::mutex> lock(mutex_);
launch_in_progress_ = false;
active_launch_.reset();
presence_request_.reset();
active_game_canonical_json_.clear();
playing_presence_published_ = false;
pending_launch_requests_.clear();
}
NotifyPresence(RobloxExperiencePresencePhase::kBrowsing, nullptr);
return Status::Error(StatusCode::kPlatformError,
"could not start experience launch worker: " +
std::to_string(start_error));
@@ -1260,6 +1267,34 @@ void* RobloxExperienceComposition::RunLaunchWorker(void* context) {
return nullptr;
}
void RobloxExperienceComposition::GamePresented(void* context,
uint64_t frame_serial) {
if (context != nullptr && frame_serial != 0) {
static_cast<RobloxExperienceComposition*>(context)
->PublishPresentedPresence(frame_serial);
}
}
void RobloxExperienceComposition::PublishPresentedPresence(
uint64_t frame_serial) {
std::optional<RobloxExperienceLaunchRequest> request;
{
std::lock_guard<std::mutex> lock(mutex_);
if (!subscribed_ || playing_presence_published_ ||
!presence_request_.has_value()) {
return;
}
playing_presence_published_ = true;
request = presence_request_;
}
std::fprintf(stderr,
" [experience] first game frame published to presence "
"frame=%llu place_id=%lld\n",
static_cast<unsigned long long>(frame_serial),
static_cast<long long>(request->place_id));
NotifyPresence(RobloxExperiencePresencePhase::kPlaying, &*request);
}
void RobloxExperienceComposition::RunActiveLaunch() {
LaunchTask* task = nullptr;
{
@@ -1401,6 +1436,8 @@ Status RobloxExperienceComposition::Shutdown() {
subscribed_ = false;
game_active_ = false;
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
lua_app_return_pending_ = false;
controlled_switch_waiting_for_return_ = false;
lua_app_surface_recreation_pending_ = false;
@@ -1481,6 +1518,14 @@ GameSessionSnapshot RobloxExperienceComposition::Snapshot() const {
return controller != nullptr ? controller->Snapshot() : GameSessionSnapshot{};
}
void RobloxExperienceComposition::NotifyPresence(
RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request) const {
if (presence_observer_.valid()) {
presence_observer_.notify(presence_observer_.context, phase, request);
}
}
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
@@ -1863,6 +1908,8 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
platform_protocols_initialized_ = false;
readiness_ = {};
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
controlled_switch_waiting_for_return_ = false;
lua_app_surface_recreation_pending_ = false;
pending_launch_requests_.clear();
@@ -1894,6 +1941,8 @@ Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
}
readiness_ = {};
active_game_canonical_json_.clear();
presence_request_.reset();
playing_presence_published_ = false;
controlled_switch_waiting_for_return_ = false;
pending_launch_requests_.clear();
next_request_id_ = 1;
@@ -361,11 +361,13 @@ Status BuildFreshStartGameParams(JNIEnv* env,
RobloxFreshGameLaunchController::RobloxFreshGameLaunchController(
JniEnvironmentProvider environment, RobloxGameSessionSymbols symbols,
RobloxFreshLaunchPresentBoundary present_boundary,
RobloxGameSurfaceJniConfig surface_config)
RobloxGameSurfaceJniConfig surface_config,
RobloxGamePresentedObserver presented_observer)
: environment_(environment),
symbols_(symbols),
present_boundary_(present_boundary),
surface_config_(std::move(surface_config)) {}
surface_config_(std::move(surface_config)),
presented_observer_(presented_observer) {}
RobloxFreshGameLaunchController::~RobloxFreshGameLaunchController() {
Shutdown();
@@ -402,8 +404,8 @@ Status RobloxFreshGameLaunchController::Launch(
return status;
}
runtime_ = std::make_unique<RobloxGameSessionRuntime>(environment_, symbols_,
surface_config_);
runtime_ = std::make_unique<RobloxGameSessionRuntime>(
environment_, symbols_, surface_config_, presented_observer_);
observer_registered_ = present_boundary_.register_observer(
present_boundary_.context,
&RobloxGameSessionRuntime::SuccessfulPresentCallback, runtime_.get());
@@ -796,9 +796,11 @@ void RobloxGameSessionNativeAdapter::ReleaseGlobalReferenceLocked(
RobloxGameSessionRuntime::RobloxGameSessionRuntime(
JniEnvironmentProvider environment, RobloxGameSessionSymbols symbols,
RobloxGameSurfaceJniConfig surface_config)
RobloxGameSurfaceJniConfig surface_config,
RobloxGamePresentedObserver presented_observer)
: adapter_(environment, symbols, std::move(surface_config)),
coordinator_(adapter_.Capabilities()) {}
coordinator_(adapter_.Capabilities()),
presented_observer_(presented_observer) {}
Status RobloxGameSessionRuntime::InitializeAndStart(
const RobloxGameSessionBinding& binding, GameSessionPrincipal principal,
@@ -910,14 +912,16 @@ void RobloxGameSessionRuntime::SuccessfulPresentCallback(
if (runtime->present_evidence_recorded_.load(std::memory_order_acquire)) {
return;
}
const GameSessionUpdateResult result =
runtime->RecordPresent(frame_serial);
if (result.ok()) {
const GameSessionUpdateResult result = runtime->RecordPresent(frame_serial);
if (result.changed()) {
runtime->present_evidence_recorded_.store(true, std::memory_order_release);
}
if (result.changed() && runtime->presented_observer_.valid()) {
runtime->presented_observer_.notify(runtime->presented_observer_.context,
frame_serial);
}
if (result.changed()) {
std::fprintf(stderr,
" [game-session] host present accepted frame=%llu\n",
std::fprintf(stderr, " [game-session] host present accepted frame=%llu\n",
static_cast<unsigned long long>(frame_serial));
}
}
+5 -12
View File
@@ -32,9 +32,7 @@ namespace runtime {
class RobloxWindowInputRuntime;
// Narrow seam between the cross-thread JNI queue and its main-thread owners.
// Production uses RobloxWindowInputRuntime and the SDL window module. Tests
// provide a deterministic implementation without creating an SDL window.
// Testable boundary between the JNI queue and its main-thread owners.
class RobloxTextInputJniBridgeBackend {
public:
virtual ~RobloxTextInputJniBridgeBackend() = default;
@@ -59,19 +57,15 @@ class RobloxTextInputJniBridgeBackend {
virtual bool RequestHideTextInput(uint64_t generation) = 0;
};
// Connects pseudo-JVM keyboard callbacks to the input runtime without
// invoking SDL or Roblox native functions from an engine callback thread.
// Guest callbacks enqueue owned commands; the registered window pre-pump
// callback drains them on SDL's main thread.
// Guest callbacks enqueue commands that the SDL pre-pump drains on its main
// thread.
class RobloxTextInputJniBridge final {
public:
static Status Create(jnivm::VM* vm,
std::shared_ptr<RobloxWindowInputRuntime> input_runtime,
std::unique_ptr<RobloxTextInputJniBridge>* bridge);
// Deterministic construction seam. The backend is retained through every
// in-flight VM callback and must obey the same main-thread contract as the
// production window implementation.
// The test backend remains alive through in-flight VM callbacks.
static Status CreateForTesting(
jnivm::VM* vm, std::shared_ptr<RobloxTextInputJniBridgeBackend> backend,
std::unique_ptr<RobloxTextInputJniBridge>* bridge);
@@ -81,8 +75,7 @@ class RobloxTextInputJniBridge final {
RobloxTextInputJniBridge(const RobloxTextInputJniBridge&) = delete;
RobloxTextInputJniBridge& operator=(const RobloxTextInputJniBridge&) = delete;
// Idempotently stops callbacks and clears pending sensitive text before the
// input runtime and SDL window are destroyed.
// Stops callbacks and clears pending sensitive text before window teardown.
Status Shutdown();
private:
+76
View File
@@ -22,6 +22,10 @@
#include <cstdlib>
#include <optional>
#ifndef MOCKTAIL_DISCORD_APPLICATION_ID
#define MOCKTAIL_DISCORD_APPLICATION_ID ""
#endif
namespace mocktail {
namespace runtime {
namespace {
@@ -41,6 +45,37 @@ bool LegacyEnabled(const Environment& environment, std::string_view name) {
return value.has_value() && !value->empty() && *value != "0";
}
bool IsDiscordApplicationId(std::string_view value) {
return value.empty() ||
(value.size() >= 17 && value.size() <= 20 &&
std::all_of(value.begin(), value.end(), [](unsigned char byte) {
return byte >= '0' && byte <= '9';
}));
}
bool IsDiscordText(std::string_view value, std::size_t maximum) {
return !value.empty() && value.size() <= maximum &&
std::none_of(value.begin(), value.end(), [](unsigned char byte) {
return byte < 0x20 || byte == 0x7f;
});
}
bool ReadBoolean(const Environment& environment, std::string_view name,
bool default_value, bool* valid) {
const std::optional<std::string> value = environment.Get(name);
if (!value.has_value()) {
return default_value;
}
if (*value == "1" || *value == "true" || *value == "on") {
return true;
}
if (*value == "0" || *value == "false" || *value == "off") {
return false;
}
*valid = false;
return default_value;
}
std::optional<bool> InputEnabled(const Environment& environment,
std::string_view name) {
const std::optional<std::string> value = environment.Get(name);
@@ -241,6 +276,47 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
config.audio_output_device_valid_ =
IsValidDeviceProfileValue(config.audio_output_device_, 512);
config.network_proxy_ = ReadNetworkProxy(environment);
bool discord_booleans_valid = true;
config.discord_rpc_.enabled = ReadBoolean(
environment, "MOCKTAIL_DISCORD_RPC_ENABLED", false,
&discord_booleans_valid);
config.discord_rpc_.show_place_name = ReadBoolean(
environment, "MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME", true,
&discord_booleans_valid);
config.discord_rpc_.show_elapsed_time = ReadBoolean(
environment, "MOCKTAIL_DISCORD_RPC_SHOW_ELAPSED_TIME", true,
&discord_booleans_valid);
config.discord_rpc_.join_enabled = ReadBoolean(
environment, "MOCKTAIL_DISCORD_RPC_JOIN_ENABLED", true,
&discord_booleans_valid);
config.discord_rpc_.public_servers_only = ReadBoolean(
environment, "MOCKTAIL_DISCORD_RPC_PUBLIC_SERVERS_ONLY", true,
&discord_booleans_valid);
config.discord_rpc_.join_button_label = environment.GetOr(
"MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL",
config.discord_rpc_.join_button_label);
config.discord_rpc_.application_id = environment.GetOr(
"MOCKTAIL_DISCORD_APPLICATION_ID", MOCKTAIL_DISCORD_APPLICATION_ID);
config.discord_rpc_.text.browsing = environment.GetOr(
"MOCKTAIL_DISCORD_RPC_TEXT_BROWSING",
config.discord_rpc_.text.browsing);
config.discord_rpc_.text.joining = environment.GetOr(
"MOCKTAIL_DISCORD_RPC_TEXT_JOINING", config.discord_rpc_.text.joining);
config.discord_rpc_.text.playing = environment.GetOr(
"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", config.discord_rpc_.text.playing);
config.discord_rpc_.text.state = environment.GetOr(
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", config.discord_rpc_.text.state);
config.discord_rpc_.text.unknown_place = environment.GetOr(
"MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE",
config.discord_rpc_.text.unknown_place);
config.discord_rpc_valid_ = discord_booleans_valid &&
IsDiscordApplicationId(config.discord_rpc_.application_id) &&
IsDiscordText(config.discord_rpc_.join_button_label, 32) &&
IsDiscordText(config.discord_rpc_.text.browsing, 128) &&
IsDiscordText(config.discord_rpc_.text.joining, 128) &&
IsDiscordText(config.discord_rpc_.text.playing, 128) &&
IsDiscordText(config.discord_rpc_.text.state, 128) &&
IsDiscordText(config.discord_rpc_.text.unknown_place, 128);
for (const std::string_view name : kUnsafeDetachedThreadOverrides) {
if (LegacyEnabled(environment, name)) {
config.unsafe_detached_thread_overrides_.emplace_back(name);
+31
View File
@@ -96,6 +96,32 @@ audio:
# To pin output, copy an exact SDL device name printed during startup.
output_device: default
integrations:
discord_rpc:
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
# This never signs in to Discord and never reads an account token.
enabled: false
# Boolean (default: true): show the current Roblox experience name.
show_place_name: true
# Boolean (default: true): show how long the current session has run.
show_elapsed_time: true
join:
# Boolean (default: true): let friends open the current experience. When
# Roblox provides a public server ID, the button targets that server.
enabled: true
# Boolean (default: true): never expose private or reserved joins.
public_servers_only: true
# button_label: Join Server
# English defaults. Uncomment only the lines you want to customize.
# text:
# browsing: Browsing experiences
# joining: Joining an experience
# playing: "{place_name}"
# state: Playing Roblox
# unknown_place: Unknown experience
# Advanced: overrides the application bundled by the package maintainer.
# application_id: 123456789012345678
window:
# Integer (default: 1280): initial window width in pixels.
width: 1280
@@ -118,6 +144,11 @@ updates:
# backend, and promote only on success. An existing current payload is
# preserved when probation fails.
automatic: true
# Boolean (default: true): testing track. Always try provider latest, even
# when it has no verified payload profile, and do not download or activate
# an older supported payload in its place. Exact HostAbi derivation and two
# canaries are still required before latest can become current.
testing_latest_only: true
# String (default: apk-pure): direct x86_64 APK provider.
source: apk-pure
# Reserved for desktop update integrations. `mocktail_updater` itself never
+163 -1
View File
@@ -19,6 +19,7 @@
#include <unistd.h>
#include <yaml.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <charconv>
@@ -97,6 +98,45 @@ bool ReadMapping(yaml_document_t* document, const yaml_node_t* mapping,
return true;
}
bool ReadNestedMapping(yaml_document_t* document, const yaml_node_t* mapping,
const std::string& section, int remaining_depth,
ValueMap* values, std::string* error) {
if (mapping == nullptr || mapping->type != YAML_MAPPING_NODE) {
*error = section + " must be a mapping";
return false;
}
std::unordered_set<std::string> keys;
for (yaml_node_pair_t* pair = mapping->data.mapping.pairs.start;
pair != mapping->data.mapping.pairs.top; ++pair) {
const yaml_node_t* key_node = yaml_document_get_node(document, pair->key);
const yaml_node_t* value_node =
yaml_document_get_node(document, pair->value);
const std::string key = Scalar(key_node);
if (key.empty() || value_node == nullptr) {
*error = section + " contains a non-scalar or empty key";
return false;
}
if (!keys.insert(key).second) {
*error = section + " contains duplicate key: " + key;
return false;
}
const std::string path = section + "." + key;
if (value_node->type == YAML_SCALAR_NODE) {
(*values)[path] = Scalar(value_node);
continue;
}
if (value_node->type != YAML_MAPPING_NODE || remaining_depth <= 0) {
*error = path + " must be a scalar";
return false;
}
if (!ReadNestedMapping(document, value_node, path, remaining_depth - 1,
values, error)) {
return false;
}
}
return true;
}
bool ParseBoolean(std::string_view value, bool* parsed) {
if (value == "true") {
*parsed = true;
@@ -157,6 +197,18 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
"compatibility.desktop_playability",
"network.proxy_host",
"network.proxy_port",
"integrations.discord_rpc.enabled",
"integrations.discord_rpc.show_place_name",
"integrations.discord_rpc.show_elapsed_time",
"integrations.discord_rpc.application_id",
"integrations.discord_rpc.join.enabled",
"integrations.discord_rpc.join.public_servers_only",
"integrations.discord_rpc.join.button_label",
"integrations.discord_rpc.text.browsing",
"integrations.discord_rpc.text.joining",
"integrations.discord_rpc.text.playing",
"integrations.discord_rpc.text.state",
"integrations.discord_rpc.text.unknown_place",
};
for (const auto& [key, ignored] : yaml) {
if (supported.find(key) == supported.end()) {
@@ -387,6 +439,78 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
(*environment)["MOCKTAIL_HTTP_PROXY_HOST"] = *proxy_host;
(*environment)["MOCKTAIL_HTTP_PROXY_PORT"] = *proxy_port;
}
for (const auto& [key, variable] : {
std::pair<std::string_view, std::string_view>(
"integrations.discord_rpc.enabled",
"MOCKTAIL_DISCORD_RPC_ENABLED"),
{"integrations.discord_rpc.show_place_name",
"MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME"},
{"integrations.discord_rpc.show_elapsed_time",
"MOCKTAIL_DISCORD_RPC_SHOW_ELAPSED_TIME"},
{"integrations.discord_rpc.join.enabled",
"MOCKTAIL_DISCORD_RPC_JOIN_ENABLED"},
{"integrations.discord_rpc.join.public_servers_only",
"MOCKTAIL_DISCORD_RPC_PUBLIC_SERVERS_ONLY"},
}) {
const std::optional<std::string> configured = value(key);
if (!configured.has_value()) {
continue;
}
bool parsed = false;
if (!ParseBoolean(*configured, &parsed)) {
*error = std::string(key) + " must be true or false";
return false;
}
(*environment)[std::string(variable)] = parsed ? "1" : "0";
}
if (const auto application_id =
value("integrations.discord_rpc.application_id");
application_id.has_value()) {
if (application_id->size() < 17 || application_id->size() > 20 ||
!std::all_of(application_id->begin(), application_id->end(),
[](unsigned char byte) {
return byte >= '0' && byte <= '9';
})) {
*error = "integrations.discord_rpc.application_id must be a Discord "
"snowflake";
return false;
}
(*environment)["MOCKTAIL_DISCORD_APPLICATION_ID"] = *application_id;
}
struct DiscordStringField {
std::string_view yaml;
std::string_view variable;
std::size_t maximum;
};
for (const DiscordStringField& field : {
DiscordStringField{"integrations.discord_rpc.join.button_label",
"MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL", 32},
{"integrations.discord_rpc.text.browsing",
"MOCKTAIL_DISCORD_RPC_TEXT_BROWSING", 128},
{"integrations.discord_rpc.text.joining",
"MOCKTAIL_DISCORD_RPC_TEXT_JOINING", 128},
{"integrations.discord_rpc.text.playing",
"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", 128},
{"integrations.discord_rpc.text.state",
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", 128},
{"integrations.discord_rpc.text.unknown_place",
"MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE", 128},
}) {
const std::optional<std::string> configured = value(field.yaml);
if (!configured.has_value()) {
continue;
}
if (configured->empty() || configured->size() > field.maximum ||
std::any_of(configured->begin(), configured->end(),
[](unsigned char byte) {
return byte < 0x20 || byte == 0x7f;
})) {
*error = std::string(field.yaml) +
" must be non-empty, bounded, and contain no control bytes";
return false;
}
(*environment)[std::string(field.variable)] = *configured;
}
return true;
}
@@ -496,6 +620,8 @@ bool LoadYaml(const std::filesystem::path& path, ValueMap* values, bool* loaded,
key == "performance" || key == "audio" || key == "window" ||
key == "input" || key == "compatibility" || key == "network") {
valid = ReadMapping(&document, value_node, key, values, error);
} else if (key == "integrations") {
valid = ReadNestedMapping(&document, value_node, key, 2, values, error);
}
// Other top-level sections are owned by subsystems such as the
// updater. This loader ignores rather than misparses them.
@@ -575,6 +701,8 @@ RuntimeConfigLoadResult LoadRuntimeConfig(
result.error = "GameMode policy is invalid";
} else if (!result.config.performance().physics_worker_mode_valid) {
result.error = "physics worker policy is invalid";
} else if (!result.config.discord_rpc_valid()) {
result.error = "Discord Rich Presence configuration is invalid";
}
return result;
}
@@ -611,6 +739,12 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
}
return false;
}
if (!config.discord_rpc_valid()) {
if (error != nullptr) {
*error = "cannot export an invalid Discord Rich Presence policy";
}
return false;
}
const DeviceProfile& device = config.device_profile();
const bool base_exported =
SetEnvironmentValue("MOCKTAIL_HEADLESS", config.headless() ? "1" : "0",
@@ -673,7 +807,35 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
GameModePolicyName(config.performance().game_mode),
error) &&
SetEnvironmentValue("MOCKTAIL_AUDIO_OUTPUT_DEVICE",
config.audio_output_device(), error);
config.audio_output_device(), error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_ENABLED",
config.discord_rpc().enabled ? "1" : "0", error) &&
SetEnvironmentValue(
"MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME",
config.discord_rpc().show_place_name ? "1" : "0", error) &&
SetEnvironmentValue(
"MOCKTAIL_DISCORD_RPC_SHOW_ELAPSED_TIME",
config.discord_rpc().show_elapsed_time ? "1" : "0", error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_JOIN_ENABLED",
config.discord_rpc().join_enabled ? "1" : "0",
error) &&
SetEnvironmentValue(
"MOCKTAIL_DISCORD_RPC_PUBLIC_SERVERS_ONLY",
config.discord_rpc().public_servers_only ? "1" : "0", error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL",
config.discord_rpc().join_button_label, error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_APPLICATION_ID",
config.discord_rpc().application_id, error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_BROWSING",
config.discord_rpc().text.browsing, error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_JOINING",
config.discord_rpc().text.joining, error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_PLAYING",
config.discord_rpc().text.playing, error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_STATE",
config.discord_rpc().text.state, error) &&
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE",
config.discord_rpc().text.unknown_place, error);
if (!base_exported) {
return false;
}
+732
View File
@@ -0,0 +1,732 @@
// Copyright 2026 Mocktail Project Authors
// Licensed under the Apache License, Version 2.0.
#include "runtime/session_log.h"
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <fstream>
#include <iostream>
#include <limits>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#ifndef MOCKTAIL_PROJECT_VERSION
#define MOCKTAIL_PROJECT_VERSION "unknown"
#endif
#ifndef MOCKTAIL_BUILD_GIT_COMMIT
#define MOCKTAIL_BUILD_GIT_COMMIT "unknown"
#endif
#ifndef MOCKTAIL_BUILD_GIT_DIRTY
#define MOCKTAIL_BUILD_GIT_DIRTY "unknown"
#endif
#ifndef MOCKTAIL_BUILD_TYPE
#define MOCKTAIL_BUILD_TYPE "unknown"
#endif
#ifndef MOCKTAIL_BUILD_COMPILER_ID
#define MOCKTAIL_BUILD_COMPILER_ID "unknown"
#endif
#ifndef MOCKTAIL_BUILD_COMPILER_VERSION
#define MOCKTAIL_BUILD_COMPILER_VERSION "unknown"
#endif
#ifndef MOCKTAIL_BUILD_TARGET_SYSTEM
#define MOCKTAIL_BUILD_TARGET_SYSTEM "unknown"
#endif
#ifndef MOCKTAIL_BUILD_TARGET_PROCESSOR
#define MOCKTAIL_BUILD_TARGET_PROCESSOR "unknown"
#endif
namespace mocktail {
namespace runtime {
namespace {
bool Enabled(const Environment& environment, std::string_view name) {
const auto value = environment.Get(name);
return value.has_value() && !value->empty() && *value != "0";
}
std::string LocalTimestamp(std::chrono::system_clock::time_point timestamp,
std::string_view format) {
const std::time_t raw = std::chrono::system_clock::to_time_t(timestamp);
std::tm local = {};
if (localtime_r(&raw, &local) == nullptr) return "unknown";
std::array<char, 64> output{};
const std::string owned_format(format);
if (std::strftime(output.data(), output.size(), owned_format.c_str(),
&local) == 0) {
return "unknown";
}
return output.data();
}
std::string IsoLocalTimestamp(std::chrono::system_clock::time_point timestamp) {
std::string formatted = LocalTimestamp(timestamp, "%Y-%m-%dT%H:%M:%S%z");
if (formatted.size() >= 5) {
const std::size_t offset = formatted.size() - 5;
if ((formatted[offset] == '+' || formatted[offset] == '-') &&
formatted[offset + 1] >= '0' && formatted[offset + 1] <= '9' &&
formatted[offset + 2] >= '0' && formatted[offset + 2] <= '9' &&
formatted[offset + 3] >= '0' && formatted[offset + 3] <= '9' &&
formatted[offset + 4] >= '0' && formatted[offset + 4] <= '9') {
formatted.insert(formatted.size() - 2, ":");
}
}
return formatted;
}
bool EnsurePrivateDirectory(const std::filesystem::path& directory,
std::string* error) {
std::error_code filesystem_error;
std::filesystem::create_directories(directory, filesystem_error);
if (filesystem_error) {
*error =
"cannot create session log directory: " + filesystem_error.message();
return false;
}
const auto status =
std::filesystem::symlink_status(directory, filesystem_error);
if (filesystem_error || !std::filesystem::is_directory(status) ||
std::filesystem::is_symlink(status)) {
*error = "session log directory is invalid or a symlink";
return false;
}
std::filesystem::permissions(directory, std::filesystem::perms::owner_all,
std::filesystem::perm_options::replace,
filesystem_error);
if (filesystem_error) {
*error = "cannot make session log directory private: " +
filesystem_error.message();
return false;
}
return true;
}
int CreateSessionFile(const std::filesystem::path& sessions,
std::chrono::system_clock::time_point started_at,
std::filesystem::path* path, std::string* error) {
const std::string timestamp = LocalTimestamp(started_at, "%Y-%m-%d_%H-%M-%S");
for (int collision = 1; collision <= 999; ++collision) {
const std::string suffix =
collision == 1 ? std::string() : "-" + std::to_string(collision);
const std::filesystem::path candidate =
sessions / (timestamp + suffix + ".log");
const int descriptor =
open(candidate.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
if (descriptor >= 0) {
*path = candidate;
return descriptor;
}
if (errno != EEXIST) {
*error =
"cannot create session log: " + std::string(std::strerror(errno));
return -1;
}
}
*error = "too many Mocktail sessions started in the same second";
return -1;
}
bool UpdateLatestLink(const std::filesystem::path& logs_root,
const std::filesystem::path& session,
std::string* warning) {
const std::filesystem::path latest = logs_root / "latest.log";
const std::filesystem::path temporary =
logs_root / (".latest.log." + std::to_string(getpid()));
const std::filesystem::path target =
std::filesystem::path("sessions") / session.filename();
(void)unlink(temporary.c_str());
if (symlink(target.c_str(), temporary.c_str()) != 0) {
if (link(session.c_str(), temporary.c_str()) != 0) {
*warning =
"cannot update latest.log: " + std::string(std::strerror(errno));
return false;
}
}
if (rename(temporary.c_str(), latest.c_str()) != 0) {
*warning =
"cannot replace latest.log: " + std::string(std::strerror(errno));
(void)unlink(temporary.c_str());
return false;
}
return true;
}
bool WriteAll(int descriptor, const char* bytes, std::size_t size) {
std::size_t offset = 0;
while (offset < size) {
const ssize_t written = write(descriptor, bytes + offset, size - offset);
if (written > 0) {
offset += static_cast<std::size_t>(written);
continue;
}
if (written < 0 && errno == EINTR) continue;
return false;
}
return true;
}
void RunTeeProcess(int stdout_reader, int stderr_reader, int log_descriptor,
int original_stdout, int original_stderr) {
(void)signal(SIGPIPE, SIG_IGN);
(void)signal(SIGINT, SIG_IGN);
(void)signal(SIGTERM, SIG_IGN);
(void)signal(SIGHUP, SIG_IGN);
struct Source {
int reader;
int output;
bool active;
bool output_active;
};
std::array<Source, 2> sources = {
{{stdout_reader, original_stdout, true, true},
{stderr_reader, original_stderr, true, true}}};
bool log_active = true;
std::array<char, 32768> buffer{};
while (sources[0].active || sources[1].active) {
std::array<pollfd, 2> descriptors = {
{{sources[0].reader, POLLIN, 0}, {sources[1].reader, POLLIN, 0}}};
if (!sources[0].active) descriptors[0].fd = -1;
if (!sources[1].active) descriptors[1].fd = -1;
const int poll_result = poll(descriptors.data(), descriptors.size(), -1);
if (poll_result < 0) {
if (errno == EINTR) continue;
break;
}
for (std::size_t index = 0; index < sources.size(); ++index) {
Source& source = sources[index];
if (!source.active ||
(descriptors[index].revents & (POLLIN | POLLHUP | POLLERR)) == 0) {
continue;
}
const ssize_t bytes = read(source.reader, buffer.data(), buffer.size());
if (bytes > 0) {
const std::size_t size = static_cast<std::size_t>(bytes);
if (log_active && !WriteAll(log_descriptor, buffer.data(), size)) {
log_active = false;
}
if (source.output_active &&
!WriteAll(source.output, buffer.data(), size)) {
source.output_active = false;
}
} else if (bytes == 0 || errno != EINTR) {
close(source.reader);
source.reader = -1;
source.active = false;
}
}
}
if (log_active) (void)fsync(log_descriptor);
close(log_descriptor);
close(original_stdout);
close(original_stderr);
}
std::filesystem::path ExecutablePath() {
std::array<char, 4097> path{};
const ssize_t size = readlink("/proc/self/exe", path.data(), path.size() - 1);
if (size <= 0 || static_cast<std::size_t>(size) >= path.size()) return {};
path[static_cast<std::size_t>(size)] = '\0';
return path.data();
}
bool IsWithin(const std::filesystem::path& child,
const std::filesystem::path& parent) {
const auto relative = child.lexically_relative(parent);
return !relative.empty() && !relative.is_absolute() &&
*relative.begin() != "..";
}
std::string DisplayPath(const std::filesystem::path& path,
const RuntimePaths& paths) {
if (path.empty()) return "unknown";
if (IsWithin(path, paths.home())) {
return (std::filesystem::path("~") / path.lexically_relative(paths.home()))
.string();
}
return path.string();
}
std::string PackageKind(const Environment& environment,
const std::filesystem::path& executable) {
if (environment.HasNonEmpty("FLATPAK_ID")) return "flatpak";
if (environment.HasNonEmpty("APPIMAGE")) return "appimage";
if (environment.HasNonEmpty("APPDIR")) return "portable";
const std::string path = executable.string();
if (path.rfind("/usr/", 0) == 0 || path.rfind("/opt/", 0) == 0 ||
path.rfind("/app/", 0) == 0) {
return "system-install";
}
return "local-build";
}
std::string DisplayServer(const Environment& environment) {
if (environment.HasNonEmpty("WAYLAND_DISPLAY")) return "wayland";
if (environment.HasNonEmpty("DISPLAY")) return "x11";
return "none";
}
std::string Trim(std::string value) {
const auto first = std::find_if_not(value.begin(), value.end(), [](char c) {
return std::isspace(static_cast<unsigned char>(c)) != 0;
});
const auto last = std::find_if_not(value.rbegin(), value.rend(), [](char c) {
return std::isspace(static_cast<unsigned char>(c)) != 0;
}).base();
if (first >= last) return {};
value = std::string(first, last);
for (char& character : value) {
const unsigned char byte = static_cast<unsigned char>(character);
if (character == '"' || std::iscntrl(byte) != 0) character = ' ';
}
return value;
}
std::optional<std::string> ReadFirstLine(const std::filesystem::path& path) {
std::ifstream input(path);
std::string value;
if (!std::getline(input, value)) return std::nullopt;
value = Trim(std::move(value));
return value.empty() ? std::nullopt
: std::optional<std::string>(std::move(value));
}
std::optional<std::string> ReadKey(const std::filesystem::path& path,
std::string_view key) {
std::ifstream input(path);
std::string line;
while (std::getline(input, line)) {
const std::size_t separator = line.find('=');
if (separator == std::string::npos ||
line.compare(0, separator, key) != 0 || separator != key.size()) {
continue;
}
std::string value = Trim(line.substr(separator + 1));
if (!value.empty()) return value;
}
return std::nullopt;
}
std::string CpuModel() {
std::ifstream input("/proc/cpuinfo");
std::string fallback;
std::string line;
while (std::getline(input, line)) {
const std::size_t separator = line.find(':');
if (separator == std::string::npos) continue;
const std::string key = Trim(line.substr(0, separator));
std::string value = Trim(line.substr(separator + 1));
if (value.empty()) continue;
if (key == "model name") return value;
if (fallback.empty() && (key == "Hardware" || key == "Processor")) {
fallback = std::move(value);
}
}
if (!fallback.empty()) return fallback;
return ReadFirstLine("/sys/devices/soc0/machine").value_or("unknown");
}
std::string TotalMemory() {
std::ifstream input("/proc/meminfo");
std::string key;
std::uint64_t kibibytes = 0;
while (input >> key) {
if (key == "MemTotal:") {
if (input >> kibibytes) {
return std::to_string((kibibytes + 512U) / 1024U) + "MiB";
}
break;
}
input.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
return "unknown";
}
std::string HexId(std::optional<std::string> value) {
if (!value.has_value()) return {};
std::string result = Trim(std::move(*value));
if (result.size() > 2 && result[0] == '0' &&
(result[1] == 'x' || result[1] == 'X')) {
result.erase(0, 2);
}
std::transform(result.begin(), result.end(), result.begin(), [](char c) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
});
if (result.size() != 4 ||
!std::all_of(result.begin(), result.end(), [](char c) {
return std::isxdigit(static_cast<unsigned char>(c)) != 0;
})) {
return {};
}
return result;
}
struct PciName {
std::string vendor;
std::string device;
};
PciName FindPciName(std::string_view vendor_id, std::string_view device_id) {
constexpr std::array<std::string_view, 3> kDatabases = {
"/usr/share/hwdata/pci.ids",
"/usr/share/misc/pci.ids",
"/usr/share/pci.ids",
};
for (const std::string_view database : kDatabases) {
std::ifstream input{std::string(database)};
if (!input) continue;
PciName result;
bool matching_vendor = false;
std::string line;
while (std::getline(input, line)) {
if (line.empty() || line[0] == '#') continue;
if (line[0] != '\t') {
if (matching_vendor) break;
if (line.size() >= 6 && line.substr(0, 4) == vendor_id &&
line[4] == ' ' && line[5] == ' ') {
matching_vendor = true;
result.vendor = Trim(line.substr(6));
}
continue;
}
if (!matching_vendor || line.size() < 7 || line[1] == '\t') continue;
if (line.substr(1, 4) == device_id && line[5] == ' ' && line[6] == ' ') {
result.device = Trim(line.substr(7));
return result;
}
}
if (!result.vendor.empty()) return result;
}
return {};
}
std::string PciVendorFallback(std::string_view vendor_id) {
if (vendor_id == "1002") return "AMD";
if (vendor_id == "10de") return "NVIDIA";
if (vendor_id == "8086") return "Intel";
if (vendor_id == "13b5") return "Arm";
if (vendor_id == "5143") return "Qualcomm";
if (vendor_id == "14e4") return "Broadcom";
if (vendor_id == "1af4") return "Virtio";
if (vendor_id == "15ad") return "VMware";
return "PCI " + std::string(vendor_id);
}
bool DeviceNodeName(std::string_view name, std::string_view prefix) {
if (name.size() <= prefix.size() || name.substr(0, prefix.size()) != prefix) {
return false;
}
return std::all_of(
name.begin() + static_cast<std::ptrdiff_t>(prefix.size()), name.end(),
[](char character) {
return std::isdigit(static_cast<unsigned char>(character)) != 0;
});
}
struct GraphicsHardware {
std::string devices = "unknown";
std::string drivers = "unknown";
};
GraphicsHardware DetectGraphicsHardware() {
std::error_code error;
std::vector<std::filesystem::path> render_nodes;
std::vector<std::filesystem::path> card_nodes;
for (std::filesystem::directory_iterator iterator("/sys/class/drm", error),
end;
!error && iterator != end; iterator.increment(error)) {
const std::string name = iterator->path().filename().string();
if (DeviceNodeName(name, "renderD")) {
render_nodes.push_back(iterator->path());
} else if (DeviceNodeName(name, "card")) {
card_nodes.push_back(iterator->path());
}
}
std::vector<std::filesystem::path>& nodes =
render_nodes.empty() ? card_nodes : render_nodes;
std::sort(nodes.begin(), nodes.end());
std::vector<std::string> seen;
std::vector<std::string> names;
std::vector<std::string> drivers;
for (const auto& node : nodes) {
error.clear();
const std::filesystem::path device =
std::filesystem::canonical(node / "device", error);
if (error) continue;
const std::string identity = device.string();
if (std::find(seen.begin(), seen.end(), identity) != seen.end()) continue;
seen.push_back(identity);
error.clear();
std::string driver = std::filesystem::read_symlink(device / "driver", error)
.filename()
.string();
if (driver.empty()) {
driver = ReadKey(device / "uevent", "DRIVER").value_or("unknown");
}
const std::string vendor_id = HexId(ReadFirstLine(device / "vendor"));
const std::string device_id = HexId(ReadFirstLine(device / "device"));
std::string name =
ReadFirstLine(device / "product_name").value_or(std::string());
if (name.empty() && !vendor_id.empty() && !device_id.empty()) {
const PciName pci = FindPciName(vendor_id, device_id);
const std::string vendor =
pci.vendor.empty() ? PciVendorFallback(vendor_id) : pci.vendor;
name = vendor +
(pci.device.empty() ? " device " + device_id : " " + pci.device);
}
if (name.empty()) name = driver;
names.push_back(Trim(std::move(name)));
drivers.push_back(Trim(std::move(driver)));
}
if (names.empty()) return {};
GraphicsHardware result;
result.devices.clear();
result.drivers.clear();
for (std::size_t index = 0; index < names.size(); ++index) {
if (index != 0) {
result.devices += "; ";
result.drivers += "; ";
}
result.devices += names[index];
result.drivers += drivers[index];
}
return result;
}
} // namespace
SessionBuildInformation CurrentSessionBuildInformation() {
SessionBuildInformation information;
information.version = MOCKTAIL_PROJECT_VERSION;
information.git_commit = MOCKTAIL_BUILD_GIT_COMMIT;
information.git_dirty = MOCKTAIL_BUILD_GIT_DIRTY;
information.build_type = MOCKTAIL_BUILD_TYPE;
information.compiler = std::string(MOCKTAIL_BUILD_COMPILER_ID) + " " +
MOCKTAIL_BUILD_COMPILER_VERSION;
information.target = std::string(MOCKTAIL_BUILD_TARGET_SYSTEM) + "/" +
MOCKTAIL_BUILD_TARGET_PROCESSOR;
return information;
}
SessionLog::~SessionLog() { Stop(); }
SessionLog::SessionLog(SessionLog&& other) noexcept {
*this = std::move(other);
}
SessionLog& SessionLog::operator=(SessionLog&& other) noexcept {
if (this == &other) return *this;
Stop();
attempted_ = other.attempted_;
active_ = other.active_;
original_stdout_ = other.original_stdout_;
original_stderr_ = other.original_stderr_;
logger_process_ = other.logger_process_;
started_at_ = other.started_at_;
path_ = std::move(other.path_);
latest_path_ = std::move(other.latest_path_);
error_ = std::move(other.error_);
warning_ = std::move(other.warning_);
other.attempted_ = false;
other.active_ = false;
other.original_stdout_ = -1;
other.original_stderr_ = -1;
other.logger_process_ = -1;
return *this;
}
SessionLog SessionLog::Start(const Environment& environment,
const RuntimePaths& paths,
std::chrono::system_clock::time_point started_at) {
SessionLog result;
result.started_at_ = started_at;
if (Enabled(environment, "MOCKTAIL_DISABLE_SESSION_LOG") ||
Enabled(environment, "MOCKTAIL_ISOLATED_CANARY")) {
return result;
}
result.attempted_ = true;
std::cout.flush();
std::cerr.flush();
(void)fflush(nullptr);
const std::filesystem::path sessions = paths.logs_root() / "sessions";
if (!EnsurePrivateDirectory(paths.logs_root(), &result.error_) ||
!EnsurePrivateDirectory(sessions, &result.error_)) {
return result;
}
const int log_descriptor =
CreateSessionFile(sessions, started_at, &result.path_, &result.error_);
if (log_descriptor < 0) return result;
result.latest_path_ = paths.logs_root() / "latest.log";
std::array<int, 2> stdout_pipe = {-1, -1};
std::array<int, 2> stderr_pipe = {-1, -1};
result.original_stdout_ = fcntl(STDOUT_FILENO, F_DUPFD_CLOEXEC, 3);
result.original_stderr_ = fcntl(STDERR_FILENO, F_DUPFD_CLOEXEC, 3);
if (result.original_stdout_ < 0 || result.original_stderr_ < 0 ||
pipe2(stdout_pipe.data(), O_CLOEXEC) != 0 ||
pipe2(stderr_pipe.data(), O_CLOEXEC) != 0) {
result.error_ = "cannot prepare session log streams: " +
std::string(std::strerror(errno));
if (stdout_pipe[0] >= 0) close(stdout_pipe[0]);
if (stdout_pipe[1] >= 0) close(stdout_pipe[1]);
if (stderr_pipe[0] >= 0) close(stderr_pipe[0]);
if (stderr_pipe[1] >= 0) close(stderr_pipe[1]);
if (result.original_stdout_ >= 0) close(result.original_stdout_);
if (result.original_stderr_ >= 0) close(result.original_stderr_);
result.original_stdout_ = -1;
result.original_stderr_ = -1;
close(log_descriptor);
(void)unlink(result.path_.c_str());
return result;
}
const pid_t child = fork();
if (child < 0) {
result.error_ =
"cannot start session log writer: " + std::string(std::strerror(errno));
close(stdout_pipe[0]);
close(stdout_pipe[1]);
close(stderr_pipe[0]);
close(stderr_pipe[1]);
close(result.original_stdout_);
close(result.original_stderr_);
result.original_stdout_ = -1;
result.original_stderr_ = -1;
close(log_descriptor);
(void)unlink(result.path_.c_str());
return result;
}
if (child == 0) {
close(stdout_pipe[1]);
close(stderr_pipe[1]);
RunTeeProcess(stdout_pipe[0], stderr_pipe[0], log_descriptor,
result.original_stdout_, result.original_stderr_);
_exit(0);
}
close(stdout_pipe[0]);
close(stderr_pipe[0]);
close(log_descriptor);
int redirect_error = 0;
const bool stdout_redirected =
dup2(stdout_pipe[1], STDOUT_FILENO) == STDOUT_FILENO;
if (!stdout_redirected) redirect_error = errno;
const bool stderr_redirected =
dup2(stderr_pipe[1], STDERR_FILENO) == STDERR_FILENO;
if (!stderr_redirected && redirect_error == 0) redirect_error = errno;
close(stdout_pipe[1]);
close(stderr_pipe[1]);
if (!stdout_redirected || !stderr_redirected) {
if (stdout_redirected) {
(void)dup2(result.original_stdout_, STDOUT_FILENO);
}
if (stderr_redirected) {
(void)dup2(result.original_stderr_, STDERR_FILENO);
}
close(result.original_stdout_);
close(result.original_stderr_);
result.original_stdout_ = -1;
result.original_stderr_ = -1;
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
result.error_ = "cannot activate session log streams: " +
std::string(std::strerror(redirect_error));
(void)unlink(result.path_.c_str());
return result;
}
(void)setvbuf(stdout, nullptr, _IOLBF, BUFSIZ);
result.logger_process_ = static_cast<int>(child);
result.active_ = true;
(void)UpdateLatestLink(paths.logs_root(), result.path_, &result.warning_);
return result;
}
std::string SessionLog::Header(const Environment& environment,
const RuntimePaths& paths,
std::string_view graphics_backend) const {
const SessionBuildInformation build = CurrentSessionBuildInformation();
const std::filesystem::path executable = ExecutablePath();
struct utsname system = {};
const bool has_system = uname(&system) == 0;
const GraphicsHardware graphics = DetectGraphicsHardware();
const unsigned int threads = std::thread::hardware_concurrency();
std::ostringstream output;
output << "[mocktail] version=" << build.version
<< " commit=" << build.git_commit;
if (build.git_dirty == "true") {
output << " dirty=true";
} else if (build.git_dirty == "unknown") {
output << " dirty=unknown";
}
output << " build=" << build.build_type << " compiler=\"" << build.compiler
<< "\"\n"
<< "[mocktail] started=" << IsoLocalTimestamp(started_at_)
<< " package=" << PackageKind(environment, executable)
<< " target=" << build.target;
if (has_system) {
output << " host=\"" << system.sysname << ' ' << system.release << ' '
<< system.machine << '"';
}
output << " pid=" << getpid() << '\n'
<< "[mocktail] cpu=\"" << CpuModel() << "\" threads=";
if (threads == 0) {
output << "unknown";
} else {
output << threads;
}
output << " ram=" << TotalMemory() << " gpu=\"" << graphics.devices
<< "\" gpu_driver=\"" << graphics.drivers
<< "\" display=" << DisplayServer(environment) << " graphics="
<< (graphics_backend.empty() ? "unknown" : graphics_backend) << '\n'
<< "[mocktail] executable=" << DisplayPath(executable, paths)
<< " config=" << DisplayPath(paths.config_file(), paths) << '\n'
<< "[mocktail] log=" << DisplayPath(path_, paths) << '\n';
if (!warning_.empty()) output << "[mocktail] warning=" << warning_ << '\n';
return output.str();
}
void SessionLog::Stop() {
if (!active_) return;
std::cout.flush();
std::cerr.flush();
(void)fflush(nullptr);
(void)dup2(original_stdout_, STDOUT_FILENO);
(void)dup2(original_stderr_, STDERR_FILENO);
close(original_stdout_);
close(original_stderr_);
original_stdout_ = -1;
original_stderr_ = -1;
const pid_t child = static_cast<pid_t>(logger_process_);
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
logger_process_ = -1;
active_ = false;
}
} // namespace runtime
} // namespace mocktail
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more