Compare commits

...
14 Commits
Author SHA1 Message Date
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
123 changed files with 3427 additions and 1785 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
+168
View File
@@ -0,0 +1,168 @@
name: Flatpak
on:
push:
branches: [main]
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'
- 'src/**'
- 'stubs/**'
- 'third_party/**'
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'
- 'src/**'
- 'stubs/**'
- 'third_party/**'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: flatpak-${{ github.ref }}
cancel-in-progress: true
jobs:
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
+24
View File
@@ -3,7 +3,31 @@ name: Native packages
on:
push:
branches: [main]
paths-ignore:
- '.github/workflows/ci.yml'
- '.github/workflows/flatpak.yml'
- '.gitignore'
- 'README.md'
- 'assets/screenshots/**'
- 'docs/**'
- 'packaging/aur/**'
- 'packaging/flatpak/**'
- 'scripts/assemble_flatpak_pages.sh'
- 'scripts/build_flatpak.sh'
- 'tests/**'
pull_request:
paths-ignore:
- '.github/workflows/ci.yml'
- '.github/workflows/flatpak.yml'
- '.gitignore'
- 'README.md'
- 'assets/screenshots/**'
- 'docs/**'
- 'packaging/aur/**'
- 'packaging/flatpak/**'
- 'scripts/assemble_flatpak_pages.sh'
- 'scripts/build_flatpak.sh'
- 'tests/**'
workflow_dispatch:
permissions:
+17 -1
View File
@@ -8,7 +8,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 0.1.0
VERSION 1.0.1
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -26,6 +26,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 +233,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
@@ -256,6 +269,9 @@ 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}\""
)
mocktail_apply_compile_options(mocktail_runtime)
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 16)
+1 -1
View File
@@ -134,7 +134,7 @@ 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
+9 -11
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
@@ -25,7 +25,7 @@ an update fails.
## Install with Flatpak
```bash
flatpak install --user https://komaruworld.github.io/mocktail/mocktail.flatpakref
flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref
flatpak run space.bigrat.mocktail
```
@@ -44,16 +44,14 @@ 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
<details>
<summary>Flatpak build</summary>
<summary>Screenshots</summary>
![Roblox home in Mocktail](docs/screenshots/flatpak-home.png)
![Roblox home in Mocktail](assets/screenshots/flatpak-home.png)
![Roblox gameplay in Mocktail](docs/screenshots/flatpak-gameplay-tower.png)
![Roblox gameplay in Mocktail](assets/screenshots/flatpak-gameplay-tower.png)
![Roblox experience in Mocktail](docs/screenshots/flatpak-gameplay-lobby.png)
![Roblox experience in Mocktail](assets/screenshots/flatpak-gameplay-lobby.png)
</details>
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)
+26
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
+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),
+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_;
};
-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 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228@gmail.com>
pkgname=mocktail
pkgver=0.1.0
pkgver=1.0.1
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.1
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.1
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.1
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": [
+18 -14
View File
@@ -4,7 +4,7 @@
<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">
@@ -13,29 +13,25 @@
<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>
@@ -44,6 +40,14 @@
<binary>mocktail</binary>
</provides>
<releases>
<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):
+7 -4
View File
@@ -9,7 +9,7 @@ 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 BASE_URL="https://mocktail.bigrat.space"
[[ -d "${REPOSITORY}" && ! -L "${REPOSITORY}" ]] || {
printf 'Flatpak repository is missing or unsafe: %s\n' "${REPOSITORY}" >&2
@@ -35,6 +35,9 @@ install -m 0644 -- "${PUBLIC_KEY}" "${OUTPUT}/mocktail-flatpak.gpg"
install -m 0644 -- \
"$(dirname -- "${BASH_SOURCE[0]}")/../packaging/space.bigrat.mocktail.svg" \
"${OUTPUT}/mocktail.svg"
install -m 0644 -- \
"$(dirname -- "${BASH_SOURCE[0]}")/../packaging/discord-join.html" \
"${OUTPUT}/join.html"
readonly GPG_KEY="$(base64 --wrap=0 "${PUBLIC_KEY}")"
@@ -57,7 +60,7 @@ 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=Play Roblox on Linux
Icon=${BASE_URL}/mocktail.svg
GPGKey=${GPG_KEY}
IsRuntime=false
@@ -92,8 +95,8 @@ cat >"${OUTPUT}/index.html" <<'EOF'
<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>
<code>flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref</code>
<p>Mocktail is an independent community project and is not affiliated with Roblox Corporation.</p>
</main></body>
</html>
EOF
-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:"
+7 -10
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)
@@ -1636,9 +1635,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")
+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.
+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();
+42 -49
View File
@@ -6,10 +6,6 @@
//
// 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 <cstddef>
#include <cstdint>
@@ -292,6 +288,42 @@ int main(int argc, char* argv[]) {
: "")
<< '\n';
}
// Register immediately after any cgroup re-exec and 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 +450,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 +751,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 +799,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();
}
+907
View File
@@ -0,0 +1,907 @@
// 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:
activity.details = TruncateUtf8(config.text.joining, 128);
break;
case RobloxExperiencePresencePhase::kPlaying: {
if (place_name.empty()) {
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 (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 (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::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) {
// Publish fallback activity while metadata is in flight.
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;
}
if (published_revision != desired.revision) {
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) {
// 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;
+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);
+26
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
+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;
}
+14 -16
View File
@@ -418,7 +418,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) {
const auto activation = ParseJson(activation_json, error);
@@ -466,15 +465,14 @@ bool ValidateCandidateApproval(const std::filesystem::path& store_root,
}
const std::string fingerprint = PayloadRuntimeFingerprint(
store_root / "payloads" / payload.payload_id, payload, error);
const std::string runtime_sha256 = HashRegularFile(runtime_binary, error);
const compat::BuildIdResult runtime_build_id =
compat::ReadElfBuildId(runtime_binary.string());
const std::string profile_sha256 = HashText(profile_bytes);
const std::string compatibility_sha256 = HashText(compatibility_bytes);
if (!error->empty() || !runtime_build_id) {
if (error->empty()) *error = runtime_build_id.error;
return false;
}
if (!error->empty()) return false;
const std::string canary_runtime_sha256 =
receipt->value("canary_runtime_sha256", "");
const std::string runtime_sha256 = receipt->value("runtime_sha256", "");
const std::string runtime_build_id =
receipt->value("runtime_build_id", "");
std::error_code filesystem_error;
const std::filesystem::path canonical_library = std::filesystem::canonical(
store_root / "payloads" / payload.payload_id / "libroblox.so",
@@ -494,15 +492,15 @@ bool ValidateCandidateApproval(const std::filesystem::path& store_root,
receipt->value("profile_sha256", "") != profile_sha256 ||
receipt->value("compatibility_manifest_sha256", "") !=
compatibility_sha256 ||
receipt->value("canary_runtime_sha256", "") != runtime_sha256 ||
receipt->value("runtime_sha256", "") != runtime_sha256 ||
receipt->value("runtime_build_id", "") != runtime_build_id.build_id ||
!IsLowerHex(canary_runtime_sha256, 64) ||
runtime_sha256 != canary_runtime_sha256 ||
!IsLowerHex(runtime_build_id, 40) ||
receipt->value("canary_tier", "") != "C" ||
receipt->value("successful_runs", 0) != 2 ||
!receipt->contains("canary_attestation_sha256") ||
!(*receipt)["canary_attestation_sha256"].is_array() ||
(*receipt)["canary_attestation_sha256"].size() != 2) {
*error = "approval receipt does not authorize exact runtime and payload";
*error = "approval receipt does not authorize exact payload evidence";
return false;
}
std::array<std::string, 2> attestation_hashes;
@@ -526,9 +524,9 @@ bool ValidateCandidateApproval(const std::filesystem::path& store_root,
attestation->value("profile_sha256", "") != profile_sha256 ||
attestation->value("compatibility_manifest_sha256", "") !=
compatibility_sha256 ||
attestation->value("runtime_sha256", "") != runtime_sha256 ||
attestation->value("runtime_build_id", "") !=
runtime_build_id.build_id ||
attestation->value("runtime_sha256", "") !=
canary_runtime_sha256 ||
attestation->value("runtime_build_id", "") != runtime_build_id ||
!IsLowerHex(attestation->value("readiness_log_sha256", ""), 64) ||
!(*receipt)["canary_attestation_sha256"][index].is_string() ||
(*receipt)["canary_attestation_sha256"][index]
@@ -543,7 +541,7 @@ bool ValidateCandidateApproval(const std::filesystem::path& store_root,
}
std::string generation_evidence;
generation_evidence.append("runtime_build_id=")
.append(runtime_build_id.build_id)
.append(runtime_build_id)
.append("\n");
generation_evidence.append("payload=").append(fingerprint).append("\n");
generation_evidence.append("profile=").append(profile_sha256).append("\n");
+50 -15
View File
@@ -360,6 +360,7 @@ nlohmann::json ActivationManifest(const PayloadIntegrityResult& payload) {
{"version_name", payload.metadata.version_name},
{"version_code", payload.metadata.version_code},
{"elf_build_id", payload.metadata.build_id},
{"payload_sha256", payload.metadata.library_sha256},
{"activated_at_epoch",
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
@@ -398,10 +399,21 @@ bool PopulateInspectedResult(
if (verified_payload != nullptr) *verified_payload = payload;
const nlohmann::json document =
nlohmann::json::parse(manifest.contents, nullptr, false, true);
if (document.is_discarded()) {
if (document.is_discarded() ||
document.value("version_name", "") != payload.metadata.version_name ||
document.value("version_code", 0ULL) != payload.metadata.version_code ||
document.value("elf_build_id", "") != payload.metadata.build_id) {
result->error = "active payload manifest is invalid";
return false;
}
const auto cached_sha256 = document.find("payload_sha256");
if (cached_sha256 != document.end() &&
(!cached_sha256->is_string() ||
cached_sha256->get_ref<const std::string&>() !=
payload.metadata.library_sha256)) {
result->error = "active payload manifest hash does not match payload";
return false;
}
const std::string profile = document.value("host_abi_profile_path", "");
const std::string compatibility =
document.value("compatibility_manifest_path", "");
@@ -418,6 +430,31 @@ bool PopulateInspectedResult(
return true;
}
bool PersistPayloadSha256(const std::filesystem::path& root,
const ManifestIdentity& manifest,
const PayloadIntegrityResult& payload,
std::string* error) {
nlohmann::json document =
nlohmann::json::parse(manifest.contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object()) {
*error = "active payload manifest is invalid";
return false;
}
const auto cached = document.find("payload_sha256");
if (cached != document.end()) {
if (!cached->is_string() ||
cached->get_ref<const std::string&>() !=
payload.metadata.library_sha256) {
*error = "active payload manifest hash does not match payload";
return false;
}
return true;
}
document["payload_sha256"] = payload.metadata.library_sha256;
return WriteAtomic(root, root / "current.json", document.dump(2) + "\n",
error);
}
} // namespace
PayloadStore::PayloadStore(std::filesystem::path root,
@@ -563,8 +600,8 @@ PayloadStoreResult PayloadStore::PromoteProbation(
const std::string contents =
ProbationActivationManifest(payload, approval).dump(2) + "\n";
CandidateApprovalResult verified_approval;
if (!ValidateCandidateApproval(root_, contents, payload, runtime_binary_,
&verified_approval, &result.error)) {
if (!ValidateCandidateApproval(root_, contents, payload, &verified_approval,
&result.error)) {
return result;
}
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
@@ -596,7 +633,7 @@ PayloadStoreResult PayloadStore::Import(
return Promote(staged.payload_id);
}
PayloadStoreResult PayloadStore::VerifyCurrent() const {
PayloadStoreResult PayloadStore::VerifyCurrent() {
PayloadStoreResult result;
StoreLock lock(root_, &result.error);
if (!lock) return result;
@@ -621,15 +658,17 @@ PayloadStoreResult PayloadStore::VerifyCurrent() const {
std::string support_error;
if (ExactSupported(compatibility_manifest_, payload.metadata,
&support_error)) {
return result;
}
if (runtime_binary_.empty()) {
result.error = "approved payload verification requires runtime identity";
if (!PersistPayloadSha256(root_, manifest, payload, &result.error)) {
return result;
}
return result;
}
CandidateApprovalResult approval;
if (!ValidateCandidateApproval(root_, manifest.contents, payload,
runtime_binary_, &approval, &result.error)) {
if (!ValidateCandidateApproval(root_, manifest.contents, payload, &approval,
&result.error)) {
return result;
}
if (!PersistPayloadSha256(root_, manifest, payload, &result.error)) {
return result;
}
result.host_abi_profile = approval.profile;
@@ -673,13 +712,9 @@ PayloadStoreResult PayloadStore::Rollback() {
std::string support_error;
if (!ExactSupported(compatibility_manifest_, payload.metadata,
&support_error)) {
if (runtime_binary_.empty()) {
result.error = "rollback approval requires runtime identity";
return result;
}
CandidateApprovalResult approval;
if (!ValidateCandidateApproval(root_, previous.contents, payload,
runtime_binary_, &approval, &result.error)) {
&approval, &result.error)) {
return result;
}
}
+61 -94
View File
@@ -12,13 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// src/window/window.cc — SDL3 window + real EGL context.
//
// Creates a real OS window via SDL3 and obtains a real EGL display + context
// from the selected host EGL/GLES driver (system GLVND or ANGLE/Vulkan).
// libroblox.so's EGL calls are intercepted in libegl_stub and redirected to
// these handles so the game engine renders into a real window.
#include "window/window.h"
#include <SDL3/SDL.h>
@@ -55,7 +48,7 @@
namespace mocktail {
namespace window {
// EGL type declarations (no system EGL headers to avoid conflicts with stubs)
// Local EGL declarations avoid conflicts with the stub headers.
using EGLDisplay = void*;
using EGLConfig = void*;
@@ -98,8 +91,6 @@ static constexpr EGLDisplay EGL_NO_DISPLAY = nullptr;
static constexpr EGLContext EGL_NO_CONTEXT = nullptr;
static constexpr EGLSurface EGL_NO_SURFACE = nullptr;
// EGL function pointer types
using PFN_eglGetDisplay = EGLDisplay (*)(EGLNativeDisplayType);
using PFN_eglInitialize = EGLBoolean (*)(EGLDisplay, EGLint*, EGLint*);
using PFN_eglBindAPI = EGLBoolean (*)(EGLenum);
@@ -121,8 +112,6 @@ using PFN_eglDestroySurface = EGLBoolean (*)(EGLDisplay, EGLSurface);
using PFN_eglTerminate = EGLBoolean (*)(EGLDisplay);
using PFN_eglGetProcAddress = __eglMustCastFP (*)(const char*);
// Module state
struct WindowState {
SDL_Window* sdl_window = nullptr;
EGLDisplay egl_display = nullptr;
@@ -551,10 +540,8 @@ void ConfigureGraphicsBackendBeforeSDL() {
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
if (ShouldUseAngleVulkanBackend() || ShouldUseNativeVulkanBackend()) {
// Mesa's implicit device-select layer is useful on desktop, but it can
// crash when ANGLE/Roblox initialise Vulkan through the Android compat
// path. The layer's JSON advertises NODEVICE_SELECT=1 as the supported
// opt-out, so use the real Vulkan loader/ICDs without that implicit layer.
// Mesa's implicit device selector can crash in the Android Vulkan path;
// its supported opt-out is NODEVICE_SELECT=1.
setenv("NODEVICE_SELECT", "1", 0);
setenv("DISABLE_LAYER_MESA_ANTI_LAG", "1", 0);
}
@@ -563,8 +550,7 @@ void ConfigureGraphicsBackendBeforeSDL() {
ResolveConfiguredVideoDriverChoice();
const char* video_driver = VideoDriverChoiceName(video_driver_choice);
if (video_driver != nullptr) {
// ResolveConfiguredVideoDriverChoice already preserves every non-empty
// user override. Overwrite only an inherited empty variable here.
// Preserve any non-empty user override.
setenv("SDL_VIDEODRIVER", video_driver, 1);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, video_driver);
}
@@ -654,9 +640,7 @@ void* QueryNativeWindowHandle() {
return wl_egl_win;
}
// Direct Vulkan creates VkSurfaceKHR from SDL_Window rather than an EGL
// window. Its SDL object is therefore the stable host-native identity when
// the backend exposes no EGL-specific Wayland handle.
// Direct Vulkan uses SDL_Window as its stable native identity.
return g_state.direct_vulkan ? g_state.sdl_window : nullptr;
}
@@ -865,8 +849,6 @@ bool RetryWithAutoAngleFallback(int width, int height, const char* title,
return Init(width, height, title);
}
// Init
Status ConfigureWindowStatePersistence(const std::filesystem::path& path) {
if (g_state.initialised) {
return Status::Error(
@@ -886,16 +868,14 @@ bool Init(int width, int height, const char* title) {
return true;
}
const Status metadata_status =
platform::ConfigureSdlApplicationMetadata();
const Status metadata_status = platform::ConfigureSdlApplicationMetadata();
if (!metadata_status.ok()) {
std::fprintf(stderr, " [window] SDL application metadata failed: %s\n",
metadata_status.message().c_str());
return false;
}
// The same-surface Vulkan overlay renders SDL_EVENT_TEXT_EDITING
// composition text. Candidate lists remain owned by the platform IME.
// The Vulkan overlay renders preedit text; the platform IME owns candidates.
SDL_SetHint(SDL_HINT_IME_IMPLEMENTED_UI, "composition");
g_state.state_persistence_active =
@@ -968,15 +948,12 @@ bool Init(int width, int height, const char* title) {
g_state.direct_vulkan = true;
g_state.initialised = true;
ResolveNativeWindowHandle();
// The Android handle is opaque to the guest-facing adapter. Keep it
// non-null even when SDL does not expose an EGL-specific Wayland handle;
// SDL_Vulkan_CreateSurface uses sdl_window directly.
// Direct Vulkan needs a non-null opaque Android handle even without an
// EGL-specific Wayland handle.
if (g_state.native_window == nullptr) {
g_state.native_window = g_state.sdl_window;
}
// Wayland does not configure a surface (and Vulkan reports undefined
// currentExtent) until the window is mapped. Direct Vulkan therefore
// cannot use the EGL-era "show on first swap" policy.
// Wayland reports undefined currentExtent until the window is mapped.
if (!SDL_ShowWindow(g_state.sdl_window)) {
fprintf(stderr, " [window] SDL_ShowWindow failed: %s\n", SDL_GetError());
SDL_DestroyWindow(g_state.sdl_window);
@@ -1006,8 +983,7 @@ bool Init(int width, int height, const char* title) {
fprintf(stderr, " [window] SDL3 direct Vulkan window created (%dx%d)\n",
width, height);
if (!g_present_lifecycle.Activate()) {
fprintf(stderr,
" [window] host present lifecycle is already active\n");
fprintf(stderr, " [window] host present lifecycle is already active\n");
g_present_lifecycle.Deactivate();
SDL_DestroyWindow(g_state.sdl_window);
g_state.sdl_window = nullptr;
@@ -1064,7 +1040,6 @@ bool Init(int width, int height, const char* title) {
width, height, title ? title : "(null)");
}
// ----- SDL3 init -----
if (!SDL_Init(SDL_INIT_VIDEO)) {
fprintf(stderr, " [window] SDL_Init failed: %s\n", SDL_GetError());
return false;
@@ -1075,10 +1050,9 @@ bool Init(int width, int height, const char* title) {
ConfigureGraphicsBackendAfterSDLInit();
// Keep the hint set in case SDL rechecks it during context creation.
// SDL may recheck this hint during context creation.
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
// Configure SDL3 GL context for OpenGL ES 3.0 / EGL
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
@@ -1090,7 +1064,6 @@ bool Init(int width, int height, const char* title) {
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
// Create the window
SDL_WindowFlags window_flags =
SDL_WINDOW_OPENGL | SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_RESIZABLE;
if (g_state.state_persistence_active && g_state.persisted_window.fullscreen) {
@@ -1121,11 +1094,9 @@ bool Init(int width, int height, const char* title) {
fprintf(stderr, " [window] SDL3 window created (%dx%d)\n", width, height);
ShowWindowAccordingToStartupMode();
// Create SDL GL/EGL context
SDL_GLContext sdl_context = SDL_GL_CreateContext(g_state.sdl_window);
if (!sdl_context && IsEnabledEnv("MOCKTAIL_ALLOW_GLES2_RESEARCH")) {
// Current production Roblox client settings reject GLES2. Retain this
// only as an explicit research probe for old payloads.
// Current Roblox clients reject GLES2; this path supports older payloads.
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
sdl_context = SDL_GL_CreateContext(g_state.sdl_window);
}
@@ -1147,7 +1118,6 @@ bool Init(int width, int height, const char* title) {
return CreateSoftwareWaitingWindow(width, height, title);
}
// Make the context current to initialize internal structures
if (!SDL_GL_MakeCurrent(g_state.sdl_window, sdl_context)) {
fprintf(stderr, " [window] SDL_GL_MakeCurrent failed: %s\n",
SDL_GetError());
@@ -1200,7 +1170,6 @@ bool Init(int width, int height, const char* title) {
return CreateSoftwareWaitingWindow(width, height, title);
}
// Query EGL handles from SDL3
g_state.egl_display = SDL_EGL_GetCurrentDisplay();
g_state.egl_config = SDL_EGL_GetCurrentConfig();
g_state.egl_surface = SDL_EGL_GetWindowSurface(g_state.sdl_window);
@@ -1239,7 +1208,7 @@ bool Init(int width, int height, const char* title) {
ResolveNativeWindowHandle();
// Unbind context on main thread so that engine thread can bind it
// The render thread binds the context after the main thread releases it.
SDL_GL_MakeCurrent(g_state.sdl_window, nullptr);
if (WindowTraceEnabled()) {
fprintf(stderr, " [window] SDL_GL_MakeCurrent(nullptr) on main thread\n");
@@ -1261,8 +1230,6 @@ bool Init(int width, int height, const char* title) {
return true;
}
// MakeCurrentOnThread — call from the engine rendering thread to bind the
// EGL context on that thread.
bool MakeCurrentOnThread() {
if (!g_state.initialised || !g_state.egl_context) {
if (WindowTraceEnabled()) {
@@ -1301,8 +1268,6 @@ bool ReleaseCurrentOnThread() {
return true;
}
// Public accessors
bool IsInitialised() { return g_state.initialised; }
bool HasPresentedFrame() { return g_real_swap_count.load() > 0; }
@@ -1631,7 +1596,7 @@ extern "C" std::uint64_t mocktail_window_note_vulkan_call_begin(
}
extern "C" void mocktail_window_note_vulkan_call_end(std::uint64_t sequence,
std::int32_t result) {
std::int32_t result) {
g_vulkan_present_progress_gate.NotifyCallEnd(
sequence, result, SDL_GetTicksNS(), SDL_GetCurrentThreadID());
}
@@ -1672,9 +1637,7 @@ void MaybeQueueInputReadinessSequence() {
SDL_Event focus{};
focus.type = SDL_EVENT_WINDOW_FOCUS_GAINED;
focus.window.windowID = window_id;
// A non-interactive readiness runner may not receive compositor focus. Send
// the same focus event before its opt-in click; production runs never
// enter this path unless MOCKTAIL_INPUT_TEST_CLICK is exactly 1.
// An opt-in readiness click may need a synthetic focus event.
const bool needs_focus =
(SDL_GetWindowFlags(g_state.sdl_window) & SDL_WINDOW_INPUT_FOCUS) == 0;
@@ -1761,8 +1724,7 @@ bool RequestFullscreenState(bool fullscreen, const char* reason) {
const bool current_fullscreen =
(SDL_GetWindowFlags(g_state.sdl_window) & SDL_WINDOW_FULLSCREEN) != 0;
if (current_fullscreen != fullscreen) {
// Capture the normal restore rectangle before SDL replaces it with the
// monitor extent.
// Save the restore rectangle before SDL replaces it with monitor bounds.
CaptureWindowState();
if (!SDL_SetWindowFullscreen(g_state.sdl_window, fullscreen)) {
fprintf(stderr, " [fullscreen] SDL request failed: %s\n",
@@ -1916,7 +1878,7 @@ void MaybeReportVulkanPresentStall() {
: 0;
const uint64_t completed_duration_ms =
report.last_completed_call_completed_ns >=
report.last_completed_call_started_ns &&
report.last_completed_call_started_ns &&
report.last_completed_call_started_ns != 0
? (report.last_completed_call_completed_ns -
report.last_completed_call_started_ns) /
@@ -1928,40 +1890,36 @@ void MaybeReportVulkanPresentStall() {
} else if (report.stage == VulkanPresentStallStage::kInsideHostPresent) {
stage = "inside_host_present";
}
fprintf(stderr,
" [vulkan] progress watchdog: stage=%s stalled_for=%llu ms "
"frame=%llu host_presents=%llu render_tid=%llu primary_call=%s "
"primary_id=%llu primary_tid=%llu last_snapshot=%s last_call=%s "
"last_id=%llu last_tid=%llu last_result=%d last_duration=%llu ms "
"completed_ago=%llu ms secondary_call=%s secondary_id=%llu "
"secondary_tid=%llu secondary_for=%llu ms\n",
stage,
static_cast<unsigned long long>(report.elapsed_ns / 1000000ULL),
static_cast<unsigned long long>(report.frame_count),
static_cast<unsigned long long>(report.host_present_count),
static_cast<unsigned long long>(report.last_present_thread_id),
report.call_name != nullptr ? report.call_name : "none",
static_cast<unsigned long long>(report.call_sequence),
static_cast<unsigned long long>(report.thread_id),
report.last_completed_call_snapshot_coherent ? "coherent"
: "unavailable",
report.last_completed_call_name != nullptr
? report.last_completed_call_name
: "none",
static_cast<unsigned long long>(
report.last_completed_call_sequence),
static_cast<unsigned long long>(
report.last_completed_call_thread_id),
report.last_completed_call_result,
static_cast<unsigned long long>(completed_duration_ms),
static_cast<unsigned long long>(completed_ago_ms),
report.oldest_active_call_name != nullptr
? report.oldest_active_call_name
: "none",
static_cast<unsigned long long>(report.oldest_active_call_sequence),
static_cast<unsigned long long>(report.oldest_active_call_thread_id),
static_cast<unsigned long long>(
report.oldest_active_call_elapsed_ns / 1000000ULL));
fprintf(
stderr,
" [vulkan] progress watchdog: stage=%s stalled_for=%llu ms "
"frame=%llu host_presents=%llu render_tid=%llu primary_call=%s "
"primary_id=%llu primary_tid=%llu last_snapshot=%s last_call=%s "
"last_id=%llu last_tid=%llu last_result=%d last_duration=%llu ms "
"completed_ago=%llu ms secondary_call=%s secondary_id=%llu "
"secondary_tid=%llu secondary_for=%llu ms\n",
stage, static_cast<unsigned long long>(report.elapsed_ns / 1000000ULL),
static_cast<unsigned long long>(report.frame_count),
static_cast<unsigned long long>(report.host_present_count),
static_cast<unsigned long long>(report.last_present_thread_id),
report.call_name != nullptr ? report.call_name : "none",
static_cast<unsigned long long>(report.call_sequence),
static_cast<unsigned long long>(report.thread_id),
report.last_completed_call_snapshot_coherent ? "coherent" : "unavailable",
report.last_completed_call_name != nullptr
? report.last_completed_call_name
: "none",
static_cast<unsigned long long>(report.last_completed_call_sequence),
static_cast<unsigned long long>(report.last_completed_call_thread_id),
report.last_completed_call_result,
static_cast<unsigned long long>(completed_duration_ms),
static_cast<unsigned long long>(completed_ago_ms),
report.oldest_active_call_name != nullptr ? report.oldest_active_call_name
: "none",
static_cast<unsigned long long>(report.oldest_active_call_sequence),
static_cast<unsigned long long>(report.oldest_active_call_thread_id),
static_cast<unsigned long long>(report.oldest_active_call_elapsed_ns /
1000000ULL));
}
bool PumpEvents() {
@@ -2046,10 +2004,9 @@ bool PumpEvents() {
g_platform_event_observer.Notify(recovered_release);
recovered_right_button_release = true;
if (!g_pointer_capture_owner->OnRightButton(
false, g_text_input_owner != nullptr &&
g_text_input_owner->active())) {
fprintf(stderr,
" [input] SDL lost-right-release recovery failed\n");
false,
g_text_input_owner != nullptr && g_text_input_owner->active())) {
fprintf(stderr, " [input] SDL lost-right-release recovery failed\n");
} else {
fprintf(stderr,
" [input] recovered missing right-button release from "
@@ -2118,6 +2075,16 @@ bool PumpEvents() {
dispatch_mouse_motion) {
g_platform_event_observer.Notify(platform_event);
}
if (converted && !fullscreen_shortcut && event.type == SDL_EVENT_KEY_DOWN &&
!event.key.repeat &&
(event.key.scancode == SDL_SCANCODE_LSHIFT ||
event.key.scancode == SDL_SCANCODE_RSHIFT) &&
g_pointer_capture_owner != nullptr &&
!g_pointer_capture_owner->OnShiftKeyPressed(
g_text_input_owner != nullptr && g_text_input_owner->active())) {
fprintf(stderr,
" [input] SDL shift-lock pointer capture update failed\n");
}
if ((event.type == SDL_EVENT_MOUSE_BUTTON_DOWN ||
event.type == SDL_EVENT_MOUSE_BUTTON_UP) &&
event.button.button == SDL_BUTTON_RIGHT &&
+22 -72
View File
@@ -12,12 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// src/window/window.h — SDL3 window + real EGL context management.
//
// Provides a real OS window and EGL surface that Roblox can render into.
// EGL calls from libroblox.so are intercepted by libegl_stub and forwarded
// to the handles created here via thread-local / global state.
#ifndef MOCKTAIL_WINDOW_WINDOW_H_
#define MOCKTAIL_WINDOW_WINDOW_H_
@@ -38,53 +32,37 @@ namespace window {
using PreTextInputPumpCallback = bool (*)(void* context);
using FullscreenStateSyncCallback = bool (*)(void* context, bool fullscreen);
// Configures the XDG-backed state record used to restore the last windowed
// geometry, maximized state, and F11 fullscreen state. Must be called before
// Init(); readiness runs suppress persistence internally.
// Configures persistent geometry and fullscreen state before Init().
Status ConfigureWindowStatePersistence(const std::filesystem::path& path);
// Initialises SDL3, creates the main window and the EGL display.
// Must be called before Stage 6 (before nativeAppBridgeV2InitWithParams).
// Returns true on success.
bool Init(int width, int height, const char* title);
// Pumps pending SDL events. Call this from a main-loop or keepalive thread.
// Returns false when the user closes the window (quit requested).
// Returns false after the user requests quit.
bool PumpEvents();
// Paces the next main-thread SDL event drain at 240 Hz. Raw mouse/touch events
// remain unsmoothed; this only prevents the host loop from batching them at
// the old 60 Hz cadence.
// Paces SDL polling at 240 Hz without smoothing raw input.
void PaceInputPump();
// Accepts GameActivity.setWindowFlags from any guest JNI thread and queues the
// relevant Android fullscreen state for SDL's next main-thread event pump.
// Returns false when the mask contains no supported fullscreen flag.
// Queues supported GameActivity fullscreen flags for the SDL thread.
bool RequestFullscreenFromAndroidWindowFlags(int flags, int mask);
// Recognizes the current Android CoreScript's otherwise-unhandled fullscreen
// menu request and queues one toggle for the SDL thread.
// Converts the Android fullscreen menu request into an SDL-thread toggle.
bool RequestFullscreenFromRobloxMenuLog(const char* tag, const char* message);
// Registers the exact-build adapter that mirrors successful SDL fullscreen
// requests into UserGameSettings.InFullScreen().
// Mirrors SDL fullscreen changes into UserGameSettings.InFullScreen().
bool SetFullscreenStateSyncCallback(FullscreenStateSyncCallback callback,
void* context);
void ClearFullscreenStateSyncCallback();
// Returns SDL-derived surface lifetime events from the same polling loop used
// by PumpEvents. The initial mapped surface is generation 1 and is available
// through GetWindowSurfaceSnapshot without a synthetic Created event.
// The initial mapped surface is generation 1 and has no synthetic Created
// event.
bool PollWindowSurfaceEvent(WindowSurfaceEvent* event);
WindowSurfaceSnapshot GetWindowSurfaceSnapshot();
// Records a compositor-observed event only after the GAME/JNI bridge has
// committed it. The default-off resize readiness gate uses this to distinguish
// a real rebind from an SDL configure notification that native code rejected.
// Records an event only after the GAME/JNI bridge commits it.
Status RecordResizeReadinessSurfaceCommit(const WindowSurfaceEvent& event);
// Completes the opt-in resize gate after Roblox reaches Stopped. Disabled
// production runs return OK without adding any work to the hot path.
// Completes the optional resize gate after Roblox reaches Stopped.
Status StopResizeReadiness();
Status ResizeReadinessCompletionStatus();
@@ -100,81 +78,60 @@ struct WindowViewportSnapshot {
}
};
// Input uses logical SDL coordinates while the native surface uses pixels.
// Keep both extents explicit so high-DPI touch normalization cannot mix them.
// Keep logical input and pixel surface extents separate on high-DPI displays.
WindowViewportSnapshot GetWindowViewportSnapshot();
// Returns the immutable SDL display snapshot captured on the window thread.
platform::DisplayRefreshCapabilities GetDisplayRefreshCapabilities();
// Registers the sole consumer of converted SDL input/focus events.
// Surface bookkeeping is internal and does not consume this observer slot.
// Registers the sole consumer of converted input and focus events.
bool SetPlatformEventObserver(PlatformEventObserver observer, void* context);
void ClearPlatformEventObserver();
// Applies Roblox's exact MouseBehavior.LockCenter state through SDL relative
// mouse mode. The callback is queried only from the SDL main thread.
// Applies MouseBehavior.LockCenter through SDL relative mouse mode.
bool SetMouseLockQueryCallback(MouseLockQueryCallback callback, void* context);
void ClearMouseLockQueryCallback();
// Registers a generic main-thread command drain that runs immediately before
// the SDL text-input owner. Clear waits for any invocation already in flight.
// Clear waits for a pre-input callback already in flight.
bool SetPreTextInputPumpCallback(PreTextInputPumpCallback callback,
void* context);
void ClearPreTextInputPumpCallback();
// Guest text callbacks only enqueue state. SDL's main-thread-only text-input
// APIs are applied by PumpEvents. The owner remains disabled until the // focused-editor bridge explicitly enables it.
// Guest callbacks enqueue state; PumpEvents applies it on SDL's main thread.
void SetWindowTextInputOwnerEnabled(bool enabled);
bool RequestShowTextInput(uint64_t generation, const TextInputArea& area,
const TextInputOptions& options);
bool RequestHideTextInput(uint64_t generation);
// Returns the native EGL display handle (EGLDisplay, opaque void*).
void* GetEGLDisplay();
// Returns the native EGL surface handle (EGLSurface, opaque void*) that
// was created from the SDL3 window. This is the surface Roblox renders into.
void* GetEGLSurface();
// Returns the native EGL context handle (EGLContext, opaque void*).
void* GetEGLContext();
// Returns the native EGL config handle (EGLConfig, opaque void*).
void* GetEGLConfig();
// Returns the raw ANativeWindow pointer used as the Android surface.
// This is what ANativeWindow_fromSurface() should return.
// Raw Android surface returned by ANativeWindow_fromSurface().
void* GetNativeWindow();
// Returns the SDL_Window as an opaque pointer for the Vulkan WSI
// adapter. Android guest code must continue to use GetNativeWindow().
// Host-only SDL_Window handle for Vulkan WSI.
void* GetBackendWindow();
// True when the window was created with SDL_WINDOW_VULKAN and no EGL context.
bool UsesDirectVulkan();
// Resolves an OpenGL ES function from SDL's currently loaded GL library.
// Returns nullptr before Init() or when no real GL backend is available.
// Returns nullptr before Init() or without a GL backend.
void* GetGLProcAddress(const char* name);
// Swaps front and back buffers (calls eglSwapBuffers on the real surface).
bool SwapBuffers();
// Returns true once the engine has presented at least one frame.
bool HasPresentedFrame();
// Records a successful host vkQueuePresentKHR. The Vulkan loader adapter is
// the only production caller; tests must not fabricate readiness with it.
// Records a real vkQueuePresentKHR; tests must not fabricate readiness.
void NoteVulkanPresent();
// Records VK_ERROR_OUT_OF_DATE_KHR without invoking SDL or JNI on the render
// thread. PumpEvents coalesces this evidence into surface rebinds.
// PumpEvents coalesces render-thread out-of-date reports into surface rebinds.
void NoteVulkanSurfaceOutOfDate();
// Registers one synchronous observer for successful host presentation through
// either vkQueuePresentKHR or SDL_GL_SwapWindow. ClearPresentObserver waits for
// an in-flight callback, allowing its context to be destroyed safely.
// ClearPresentObserver waits for an in-flight host-present callback.
bool SetPresentObserver(PresentObserver observer, void* context);
void ClearPresentObserver();
@@ -193,24 +150,17 @@ class ScopedPresentObserver final {
bool registered_ = false;
};
// Shows the SDL window if it was created hidden for startup.
void ShowIfHidden();
// Binds the EGL context to the calling thread.
// Must be called from the engine rendering thread before any GL calls.
// GLdispatch (Mesa) initialises TLS on the first MakeCurrent per thread.
// The render thread must bind EGL before GLdispatch initializes its TLS.
bool MakeCurrentOnThread();
// Releases the EGL context from the calling thread.
bool ReleaseCurrentOnThread();
// Destroys the window and shuts down SDL3.
void Shutdown();
// Returns true if Init() was called successfully.
bool IsInitialised();
// Returns current window dimensions.
int GetWidth();
int GetHeight();
+22 -13
View File
@@ -82,9 +82,8 @@ bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
wait_for_native_unlock_after_right_drag_ = false;
}
const bool client_active = query_ok && focused_;
const bool native_capture_active =
client_active && native_lock_active &&
!wait_for_native_unlock_after_right_drag_;
const bool native_capture_active = client_active && native_lock_active &&
!wait_for_native_unlock_after_right_drag_;
const bool right_drag_active = focused_ && right_button_held_;
const bool capture_requested =
(right_drag_active || native_capture_active) && !text_input_active;
@@ -100,20 +99,31 @@ bool WindowPointerCaptureOwner::OnRightButton(bool pressed,
// as Roblox receives RMB down. Remember only a lock observed before the
// drag so a stale post-release value cannot capture SDL again.
native_lock_was_active_before_right_drag_ =
native_lock_observed_ &&
!wait_for_native_unlock_after_right_drag_;
native_lock_observed_ && !wait_for_native_unlock_after_right_drag_;
shift_key_pressed_during_right_drag_ = false;
discard_next_motion_after_right_drag_ = false;
} else if (!pressed && right_button_held_) {
const bool transient_right_drag =
!native_lock_was_active_before_right_drag_;
const bool native_lock_should_survive_right_drag =
native_lock_was_active_before_right_drag_ ||
(shift_key_pressed_during_right_drag_ && native_lock_observed_);
const bool transient_right_drag = !native_lock_should_survive_right_drag;
wait_for_native_unlock_after_right_drag_ = transient_right_drag;
discard_next_motion_after_right_drag_ = transient_right_drag;
native_lock_was_active_before_right_drag_ = false;
shift_key_pressed_during_right_drag_ = false;
}
right_button_held_ = focused_ && pressed;
return Pump(text_input_active);
}
bool WindowPointerCaptureOwner::OnShiftKeyPressed(bool text_input_active) {
if (!focused_ || !right_button_held_) {
return true;
}
shift_key_pressed_during_right_drag_ = true;
return Pump(text_input_active);
}
bool WindowPointerCaptureOwner::NeedsRightButtonReleaseRecovery(
bool observed_pressed) const {
return right_button_held_ && !observed_pressed;
@@ -135,6 +145,7 @@ bool WindowPointerCaptureOwner::OnFocusLost() {
right_button_held_ = false;
native_lock_observed_ = false;
native_lock_was_active_before_right_drag_ = false;
shift_key_pressed_during_right_drag_ = false;
wait_for_native_unlock_after_right_drag_ = false;
discard_next_motion_after_right_drag_ = false;
return Apply(false, true);
@@ -146,6 +157,7 @@ bool WindowPointerCaptureOwner::Shutdown() {
right_button_held_ = false;
native_lock_observed_ = false;
native_lock_was_active_before_right_drag_ = false;
shift_key_pressed_during_right_drag_ = false;
wait_for_native_unlock_after_right_drag_ = false;
discard_next_motion_after_right_drag_ = false;
return Apply(false, true);
@@ -166,11 +178,9 @@ bool WindowPointerCaptureOwner::Apply(bool capture, bool cursor_visible) {
captured_ = capture;
cursor_visible_ = cursor_visible;
if (capture_changed && capture) {
std::fprintf(stderr,
" [input] SDL relative pointer capture enabled\n");
std::fprintf(stderr, " [input] SDL relative pointer capture enabled\n");
} else if (capture_changed) {
std::fprintf(stderr,
" [input] SDL relative pointer capture released\n");
std::fprintf(stderr, " [input] SDL relative pointer capture released\n");
}
if (cursor_changed) {
std::fprintf(stderr, " [input] SDL system cursor %s\n",
@@ -184,8 +194,7 @@ bool WindowPointerCaptureOwner::Apply(bool capture, bool cursor_visible) {
return false;
}
bool SdlPointerCaptureBackend::Apply(bool relative_mode,
bool cursor_visible) {
bool SdlPointerCaptureBackend::Apply(bool relative_mode, bool cursor_visible) {
auto* window = static_cast<SDL_Window*>(window_);
if (window == nullptr) {
return false;
@@ -45,6 +45,10 @@ class WindowPointerCaptureOwner final {
// Android's lock-center query does not represent desktop RMB camera drag,
// so the SDL owner tracks that transient capture source independently.
bool OnRightButton(bool pressed, bool text_input_active);
// Shift can enable Roblox's persistent mouse lock while a transient RMB
// camera drag is active. Refresh the native state after dispatching that
// key so the subsequent RMB release preserves the intentional lock.
bool OnShiftKeyPressed(bool text_input_active);
bool NeedsRightButtonReleaseRecovery(bool observed_pressed) const;
// Mirrors Android's captured-pointer listener: the motion that releases a
// transient capture is consumed instead of leaking one final camera delta.
@@ -70,6 +74,7 @@ class WindowPointerCaptureOwner final {
bool right_button_held_ = false;
bool native_lock_observed_ = false;
bool native_lock_was_active_before_right_drag_ = false;
bool shift_key_pressed_during_right_drag_ = false;
bool wait_for_native_unlock_after_right_drag_ = false;
bool discard_next_motion_after_right_drag_ = false;
bool captured_ = false;
+2 -3
View File
@@ -1,6 +1,5 @@
// Copyright 2026 Mocktail Project Authors
// Apache 2.0 License
//
// stubs/empty.cc — empty translation unit.
// Used as a placeholder source file for stub .so targets that only need
// to set SONAME and pull in a real system library via DT_NEEDED.
// Placeholder for stub libraries that only set SONAME and add a system
// dependency through DT_NEEDED.
+23 -26
View File
@@ -1,11 +1,8 @@
// Copyright 2026 Mocktail Project Authors
// Apache 2.0 License
//
// stubs/libandroid_stub.cc — Stub implementations of Android NDK functions.
//
// libroblox.so links against libandroid.so which does not exist on Linux.
// This stub satisfies the dynamic linker so dlopen() can load libroblox.so.
// All functions are no-ops or return safe sentinel values.
// Android NDK compatibility symbols used by libroblox.so on Linux. Asset calls
// can read from an extracted directory or directly from an APK.
//
// Symbols sourced from Android NDK r28 android/native_activity.h,
// android/asset_manager.h, android/looper.h, android/configuration.h.
@@ -30,7 +27,7 @@
#include <string>
#include <vector>
// AAssetManager / AAsset — read-only asset access
// Read-only asset access
struct AAssetManager {};
struct AAsset {
@@ -260,7 +257,7 @@ bool MocktailUsesDirectVulkan() {
extern "C" {
AAsset* AAssetManager_open(AAssetManager* mgr, const char* filename,
int /*mode*/) {
int) {
if (mgr == nullptr) {
return nullptr;
}
@@ -418,7 +415,7 @@ int AAsset_openFileDescriptor64(AAsset* asset, off_t* outStart,
return AAsset_openFileDescriptor(asset, outStart, outLength);
}
AAssetManager* AAssetManager_fromJava(void* /*env*/, void* /*assetManager*/) {
AAssetManager* AAssetManager_fromJava(void*, void*) {
return &g_asset_manager;
}
@@ -432,36 +429,36 @@ AConfiguration* AConfiguration_new() {
void AConfiguration_delete(AConfiguration* config) { free(config); }
void AConfiguration_fromAssetManager(AConfiguration* /*config*/,
AAssetManager* /*am*/) {}
void AConfiguration_fromAssetManager(AConfiguration*,
AAssetManager*) {}
void AConfiguration_getLanguage(AConfiguration* /*config*/, char* out) {
void AConfiguration_getLanguage(AConfiguration*, char* out) {
if (out) {
out[0] = 'e';
out[1] = 'n';
}
}
void AConfiguration_getCountry(AConfiguration* /*config*/, char* out) {
void AConfiguration_getCountry(AConfiguration*, char* out) {
if (out) {
out[0] = 'U';
out[1] = 'S';
}
}
int32_t AConfiguration_getScreenSize(AConfiguration* /*config*/) {
int32_t AConfiguration_getScreenSize(AConfiguration*) {
return 2; // ACONFIGURATION_SCREENSIZE_NORMAL
}
int32_t AConfiguration_getScreenWidthDp(AConfiguration* /*config*/) {
int32_t AConfiguration_getScreenWidthDp(AConfiguration*) {
return 360;
}
int32_t AConfiguration_getScreenHeightDp(AConfiguration* /*config*/) {
int32_t AConfiguration_getScreenHeightDp(AConfiguration*) {
return 640;
}
int32_t AConfiguration_getNavHidden(AConfiguration* /*config*/) {
int32_t AConfiguration_getNavHidden(AConfiguration*) {
return 1; // ACONFIGURATION_NAVHIDDEN_YES
}
@@ -469,7 +466,7 @@ int32_t AConfiguration_getNavHidden(AConfiguration* /*config*/) {
struct ALooper {};
ALooper* ALooper_prepare(int /*opts*/) {
ALooper* ALooper_prepare(int) {
static ALooper kLooper;
return &kLooper;
}
@@ -479,18 +476,18 @@ ALooper* ALooper_forThread() {
return &kLooper;
}
void ALooper_acquire(ALooper* /*looper*/) {}
void ALooper_release(ALooper* /*looper*/) {}
void ALooper_acquire(ALooper*) {}
void ALooper_release(ALooper*) {}
int ALooper_addFd(ALooper* /*looper*/, int /*fd*/, int /*ident*/,
int /*events*/, void* /*callback*/, void* /*data*/) {
int ALooper_addFd(ALooper*, int, int,
int, void*, void*) {
return 1;
}
int ALooper_removeFd(ALooper* /*looper*/, int /*fd*/) { return 1; }
int ALooper_removeFd(ALooper*, int) { return 1; }
int ALooper_pollOnce(int /*timeoutMillis*/, int* /*outFd*/, int* /*outEvents*/,
void** /*outData*/) {
int ALooper_pollOnce(int, int*, int*,
void**) {
return -2; // ALOOPER_POLL_TIMEOUT
}
@@ -498,8 +495,8 @@ int ALooper_pollOnce(int /*timeoutMillis*/, int* /*outFd*/, int* /*outEvents*/,
struct ANativeWindow {};
void ANativeWindow_acquire(ANativeWindow* /*window*/) {}
void ANativeWindow_release(ANativeWindow* /*window*/) {}
void ANativeWindow_acquire(ANativeWindow*) {}
void ANativeWindow_release(ANativeWindow*) {}
ANativeWindow* ANativeWindow_fromSurface(void* env, void* surface) {
void* native_window = MocktailNativeWindow();
+7 -7
View File
@@ -220,7 +220,7 @@ void SetError(EGLint error) {
extern "C" {
EGLDisplay eglGetDisplay(EGLNativeDisplayType /*display_id*/) {
EGLDisplay eglGetDisplay(EGLNativeDisplayType) {
EGLDisplay display = ActiveDisplay();
SetError(display != nullptr ? kEglSuccess : kEglNotInitialized);
return display;
@@ -250,7 +250,7 @@ EGLBoolean eglBindAPI(EGLenum api) {
return kEglTrue;
}
EGLBoolean eglChooseConfig(EGLDisplay display, const EGLint* /*attrib_list*/,
EGLBoolean eglChooseConfig(EGLDisplay display, const EGLint*,
EGLConfig* configs, EGLint config_size,
EGLint* num_config) {
if (!IsActiveDisplay(display)) {
@@ -292,7 +292,7 @@ EGLBoolean eglGetConfigAttrib(EGLDisplay display, EGLConfig config,
EGLSurface eglCreateWindowSurface(EGLDisplay display, EGLConfig config,
EGLNativeWindowType native_window,
const EGLint* /*attrib_list*/) {
const EGLint*) {
if (!IsActiveDisplay(display) || config == nullptr ||
config != ActiveConfig()) {
SetError(kEglBadDisplay);
@@ -314,7 +314,7 @@ EGLSurface eglCreateWindowSurface(EGLDisplay display, EGLConfig config,
}
EGLSurface eglCreatePbufferSurface(EGLDisplay display, EGLConfig config,
const EGLint* /*attrib_list*/) {
const EGLint*) {
if (!IsActiveDisplay(display) || config == nullptr ||
config != ActiveConfig()) {
SetError(kEglBadDisplay);
@@ -329,8 +329,8 @@ EGLSurface eglCreatePbufferSurface(EGLDisplay display, EGLConfig config,
}
EGLContext eglCreateContext(EGLDisplay display, EGLConfig config,
EGLContext /*share_context*/,
const EGLint* /*attrib_list*/) {
EGLContext,
const EGLint*) {
if (!IsActiveDisplay(display) || config == nullptr ||
config != ActiveConfig()) {
SetError(kEglBadDisplay);
@@ -427,7 +427,7 @@ EGLBoolean eglSwapBuffers(EGLDisplay display, EGLSurface surface) {
return swapped ? kEglTrue : kEglFalse;
}
EGLBoolean eglSwapInterval(EGLDisplay display, EGLint /*interval*/) {
EGLBoolean eglSwapInterval(EGLDisplay display, EGLint) {
if (!IsActiveDisplay(display) || g_current_context == nullptr) {
SetError(kEglBadDisplay);
return kEglFalse;
+96 -96
View File
@@ -176,10 +176,10 @@ void glGenRenderbuffers(GLsizei n, GLuint* renderbuffers) {
FillObjects(n, renderbuffers);
}
GLuint glCreateShader(GLenum /*type*/) { return NextObject(); }
GLuint glCreateShader(GLenum) { return NextObject(); }
GLuint glCreateProgram() { return NextObject(); }
void glGetShaderiv(GLuint /*shader*/, GLenum pname, GLint* params) {
void glGetShaderiv(GLuint, GLenum pname, GLint* params) {
if (params == nullptr) {
return;
}
@@ -196,7 +196,7 @@ void glGetShaderiv(GLuint /*shader*/, GLenum pname, GLint* params) {
}
}
void glGetProgramiv(GLuint /*program*/, GLenum pname, GLint* params) {
void glGetProgramiv(GLuint, GLenum pname, GLint* params) {
if (params == nullptr) {
return;
}
@@ -213,7 +213,7 @@ void glGetProgramiv(GLuint /*program*/, GLenum pname, GLint* params) {
}
}
void glGetShaderInfoLog(GLuint /*shader*/, GLsizei buf_size, GLsizei* length,
void glGetShaderInfoLog(GLuint, GLsizei buf_size, GLsizei* length,
GLchar* info_log) {
if (!RequireTestGraphicsStub()) {
CopyString("real GLES backend unavailable", buf_size, length, info_log);
@@ -222,7 +222,7 @@ void glGetShaderInfoLog(GLuint /*shader*/, GLsizei buf_size, GLsizei* length,
CopyString("", buf_size, length, info_log);
}
void glGetProgramInfoLog(GLuint /*program*/, GLsizei buf_size, GLsizei* length,
void glGetProgramInfoLog(GLuint, GLsizei buf_size, GLsizei* length,
GLchar* info_log) {
if (!RequireTestGraphicsStub()) {
CopyString("real GLES backend unavailable", buf_size, length, info_log);
@@ -231,7 +231,7 @@ void glGetProgramInfoLog(GLuint /*program*/, GLsizei buf_size, GLsizei* length,
CopyString("", buf_size, length, info_log);
}
void glGetActiveUniform(GLuint /*program*/, GLuint /*index*/, GLsizei buf_size,
void glGetActiveUniform(GLuint, GLuint, GLsizei buf_size,
GLsizei* length, GLint* size, GLenum* type,
GLchar* name) {
if (!RequireTestGraphicsStub()) {
@@ -253,11 +253,11 @@ void glGetActiveUniform(GLuint /*program*/, GLuint /*index*/, GLsizei buf_size,
CopyString("", buf_size, length, name);
}
GLint glGetUniformLocation(GLuint /*program*/, const GLchar* /*name*/) {
GLint glGetUniformLocation(GLuint, const GLchar*) {
return RequireTestGraphicsStub() ? 0 : -1;
}
GLenum glCheckFramebufferStatus(GLenum /*target*/) {
GLenum glCheckFramebufferStatus(GLenum) {
return RequireTestGraphicsStub() ? kGlFramebufferComplete
: kGlFramebufferUnsupported;
}
@@ -272,119 +272,119 @@ void glViewport(GLint x, GLint y, GLsizei width, GLsizei height) {
g_viewport[3] = height;
}
void glActiveTexture(GLenum /*texture*/) { RequireTestGraphicsStub(); }
void glAttachShader(GLuint /*program*/, GLuint /*shader*/) {
void glActiveTexture(GLenum) { RequireTestGraphicsStub(); }
void glAttachShader(GLuint, GLuint) {
RequireTestGraphicsStub();
}
void glBindAttribLocation(GLuint /*program*/, GLuint /*index*/,
const GLchar* /*name*/) {
void glBindAttribLocation(GLuint, GLuint,
const GLchar*) {
RequireTestGraphicsStub();
}
void glBindBuffer(GLenum /*target*/, GLuint /*buffer*/) {
void glBindBuffer(GLenum, GLuint) {
RequireTestGraphicsStub();
}
void glBindFramebuffer(GLenum /*target*/, GLuint /*framebuffer*/) {
void glBindFramebuffer(GLenum, GLuint) {
RequireTestGraphicsStub();
}
void glBindRenderbuffer(GLenum /*target*/, GLuint /*renderbuffer*/) {
void glBindRenderbuffer(GLenum, GLuint) {
RequireTestGraphicsStub();
}
void glBindTexture(GLenum /*target*/, GLuint /*texture*/) {
void glBindTexture(GLenum, GLuint) {
RequireTestGraphicsStub();
}
void glBlendFunc(GLenum /*sfactor*/, GLenum /*dfactor*/) {
void glBlendFunc(GLenum, GLenum) {
RequireTestGraphicsStub();
}
void glBlendFuncSeparate(GLenum /*src_rgb*/, GLenum /*dst_rgb*/,
GLenum /*src_alpha*/, GLenum /*dst_alpha*/) {
void glBlendFuncSeparate(GLenum, GLenum,
GLenum, GLenum) {
RequireTestGraphicsStub();
}
void glBufferData(GLenum /*target*/, GLsizeiptr /*size*/, const void* /*data*/,
GLenum /*usage*/) {
void glBufferData(GLenum, GLsizeiptr, const void*,
GLenum) {
RequireTestGraphicsStub();
}
void glBufferSubData(GLenum /*target*/, GLintptr /*offset*/,
GLsizeiptr /*size*/, const void* /*data*/) {
void glBufferSubData(GLenum, GLintptr,
GLsizeiptr, const void*) {
RequireTestGraphicsStub();
}
void glClear(GLbitfield /*mask*/) { RequireTestGraphicsStub(); }
void glClearColor(GLfloat /*red*/, GLfloat /*green*/, GLfloat /*blue*/,
GLfloat /*alpha*/) {
void glClear(GLbitfield) { RequireTestGraphicsStub(); }
void glClearColor(GLfloat, GLfloat, GLfloat,
GLfloat) {
RequireTestGraphicsStub();
}
void glClearDepthf(GLfloat /*depth*/) { RequireTestGraphicsStub(); }
void glClearStencil(GLint /*s*/) { RequireTestGraphicsStub(); }
void glColorMask(GLboolean /*red*/, GLboolean /*green*/, GLboolean /*blue*/,
GLboolean /*alpha*/) {
void glClearDepthf(GLfloat) { RequireTestGraphicsStub(); }
void glClearStencil(GLint) { RequireTestGraphicsStub(); }
void glColorMask(GLboolean, GLboolean, GLboolean,
GLboolean) {
RequireTestGraphicsStub();
}
void glCompileShader(GLuint /*shader*/) { RequireTestGraphicsStub(); }
void glCompressedTexImage2D(GLenum /*target*/, GLint /*level*/,
GLenum /*internalformat*/, GLsizei /*width*/,
GLsizei /*height*/, GLint /*border*/,
GLsizei /*image_size*/, const void* /*data*/) {
void glCompileShader(GLuint) { RequireTestGraphicsStub(); }
void glCompressedTexImage2D(GLenum, GLint,
GLenum, GLsizei,
GLsizei, GLint,
GLsizei, const void*) {
RequireTestGraphicsStub();
}
void glCompressedTexSubImage2D(GLenum /*target*/, GLint /*level*/,
GLint /*xoffset*/, GLint /*yoffset*/,
GLsizei /*width*/, GLsizei /*height*/,
GLenum /*format*/, GLsizei /*image_size*/,
const void* /*data*/) {
void glCompressedTexSubImage2D(GLenum, GLint,
GLint, GLint,
GLsizei, GLsizei,
GLenum, GLsizei,
const void*) {
RequireTestGraphicsStub();
}
void glCopyTexSubImage2D(GLenum /*target*/, GLint /*level*/, GLint /*xoffset*/,
GLint /*yoffset*/, GLint /*x*/, GLint /*y*/,
GLsizei /*width*/, GLsizei /*height*/) {
void glCopyTexSubImage2D(GLenum, GLint, GLint,
GLint, GLint, GLint,
GLsizei, GLsizei) {
RequireTestGraphicsStub();
}
void glCullFace(GLenum /*mode*/) { RequireTestGraphicsStub(); }
void glDeleteBuffers(GLsizei /*n*/, const GLuint* /*buffers*/) {
void glCullFace(GLenum) { RequireTestGraphicsStub(); }
void glDeleteBuffers(GLsizei, const GLuint*) {
RequireTestGraphicsStub();
}
void glDeleteFramebuffers(GLsizei /*n*/, const GLuint* /*framebuffers*/) {
void glDeleteFramebuffers(GLsizei, const GLuint*) {
RequireTestGraphicsStub();
}
void glDeleteProgram(GLuint /*program*/) { RequireTestGraphicsStub(); }
void glDeleteRenderbuffers(GLsizei /*n*/, const GLuint* /*renderbuffers*/) {
void glDeleteProgram(GLuint) { RequireTestGraphicsStub(); }
void glDeleteRenderbuffers(GLsizei, const GLuint*) {
RequireTestGraphicsStub();
}
void glDeleteShader(GLuint /*shader*/) { RequireTestGraphicsStub(); }
void glDeleteTextures(GLsizei /*n*/, const GLuint* /*textures*/) {
void glDeleteShader(GLuint) { RequireTestGraphicsStub(); }
void glDeleteTextures(GLsizei, const GLuint*) {
RequireTestGraphicsStub();
}
void glDepthFunc(GLenum /*func*/) { RequireTestGraphicsStub(); }
void glDepthMask(GLboolean /*flag*/) { RequireTestGraphicsStub(); }
void glDisable(GLenum /*cap*/) { RequireTestGraphicsStub(); }
void glDisableVertexAttribArray(GLuint /*index*/) { RequireTestGraphicsStub(); }
void glDrawArrays(GLenum /*mode*/, GLint /*first*/, GLsizei /*count*/) {
void glDepthFunc(GLenum) { RequireTestGraphicsStub(); }
void glDepthMask(GLboolean) { RequireTestGraphicsStub(); }
void glDisable(GLenum) { RequireTestGraphicsStub(); }
void glDisableVertexAttribArray(GLuint) { RequireTestGraphicsStub(); }
void glDrawArrays(GLenum, GLint, GLsizei) {
RequireTestGraphicsStub();
}
void glDrawElements(GLenum /*mode*/, GLsizei /*count*/, GLenum /*type*/,
const void* /*indices*/) {
void glDrawElements(GLenum, GLsizei, GLenum,
const void*) {
RequireTestGraphicsStub();
}
void glEnable(GLenum /*cap*/) { RequireTestGraphicsStub(); }
void glEnableVertexAttribArray(GLuint /*index*/) { RequireTestGraphicsStub(); }
void glFramebufferRenderbuffer(GLenum /*target*/, GLenum /*attachment*/,
GLenum /*renderbuffertarget*/,
GLuint /*renderbuffer*/) {
void glEnable(GLenum) { RequireTestGraphicsStub(); }
void glEnableVertexAttribArray(GLuint) { RequireTestGraphicsStub(); }
void glFramebufferRenderbuffer(GLenum, GLenum,
GLenum,
GLuint) {
RequireTestGraphicsStub();
}
void glFramebufferTexture2D(GLenum /*target*/, GLenum /*attachment*/,
GLenum /*textarget*/, GLuint /*texture*/,
GLint /*level*/) {
void glFramebufferTexture2D(GLenum, GLenum,
GLenum, GLuint,
GLint) {
RequireTestGraphicsStub();
}
void glGenerateMipmap(GLenum /*target*/) { RequireTestGraphicsStub(); }
void glLinkProgram(GLuint /*program*/) { RequireTestGraphicsStub(); }
void glPixelStorei(GLenum /*pname*/, GLint /*param*/) {
void glGenerateMipmap(GLenum) { RequireTestGraphicsStub(); }
void glLinkProgram(GLuint) { RequireTestGraphicsStub(); }
void glPixelStorei(GLenum, GLint) {
RequireTestGraphicsStub();
}
void glPolygonOffset(GLfloat /*factor*/, GLfloat /*units*/) {
void glPolygonOffset(GLfloat, GLfloat) {
RequireTestGraphicsStub();
}
void glReadPixels(GLint /*x*/, GLint /*y*/, GLsizei width, GLsizei height,
GLenum /*format*/, GLenum /*type*/, void* pixels) {
void glReadPixels(GLint, GLint, GLsizei width, GLsizei height,
GLenum, GLenum, void* pixels) {
if (!RequireTestGraphicsStub()) {
return;
}
@@ -394,53 +394,53 @@ void glReadPixels(GLint /*x*/, GLint /*y*/, GLsizei width, GLsizei height,
}
}
void glReleaseShaderCompiler() { RequireTestGraphicsStub(); }
void glRenderbufferStorage(GLenum /*target*/, GLenum /*internalformat*/,
GLsizei /*width*/, GLsizei /*height*/) {
void glRenderbufferStorage(GLenum, GLenum,
GLsizei, GLsizei) {
RequireTestGraphicsStub();
}
void glScissor(GLint /*x*/, GLint /*y*/, GLsizei /*width*/,
GLsizei /*height*/) {
void glScissor(GLint, GLint, GLsizei,
GLsizei) {
RequireTestGraphicsStub();
}
void glShaderSource(GLuint /*shader*/, GLsizei /*count*/,
const GLchar* const* /*string*/, const GLint* /*length*/) {
void glShaderSource(GLuint, GLsizei,
const GLchar* const*, const GLint*) {
RequireTestGraphicsStub();
}
void glStencilFunc(GLenum /*func*/, GLint /*ref*/, GLuint /*mask*/) {
void glStencilFunc(GLenum, GLint, GLuint) {
RequireTestGraphicsStub();
}
void glStencilMask(GLuint /*mask*/) { RequireTestGraphicsStub(); }
void glStencilOp(GLenum /*fail*/, GLenum /*zfail*/, GLenum /*zpass*/) {
void glStencilMask(GLuint) { RequireTestGraphicsStub(); }
void glStencilOp(GLenum, GLenum, GLenum) {
RequireTestGraphicsStub();
}
void glTexImage2D(GLenum /*target*/, GLint /*level*/, GLint /*internalformat*/,
GLsizei /*width*/, GLsizei /*height*/, GLint /*border*/,
GLenum /*format*/, GLenum /*type*/, const void* /*pixels*/) {
void glTexImage2D(GLenum, GLint, GLint,
GLsizei, GLsizei, GLint,
GLenum, GLenum, const void*) {
RequireTestGraphicsStub();
}
void glTexParameterf(GLenum /*target*/, GLenum /*pname*/, GLfloat /*param*/) {
void glTexParameterf(GLenum, GLenum, GLfloat) {
RequireTestGraphicsStub();
}
void glTexParameterfv(GLenum /*target*/, GLenum /*pname*/,
const GLfloat* /*params*/) {
void glTexParameterfv(GLenum, GLenum,
const GLfloat*) {
RequireTestGraphicsStub();
}
void glTexParameteri(GLenum /*target*/, GLenum /*pname*/, GLint /*param*/) {
void glTexParameteri(GLenum, GLenum, GLint) {
RequireTestGraphicsStub();
}
void glTexSubImage2D(GLenum /*target*/, GLint /*level*/, GLint /*xoffset*/,
GLint /*yoffset*/, GLsizei /*width*/, GLsizei /*height*/,
GLenum /*format*/, GLenum /*type*/,
const void* /*pixels*/) {
void glTexSubImage2D(GLenum, GLint, GLint,
GLint, GLsizei, GLsizei,
GLenum, GLenum,
const void*) {
RequireTestGraphicsStub();
}
void glUniform1i(GLint /*location*/, GLint /*v0*/) {
void glUniform1i(GLint, GLint) {
RequireTestGraphicsStub();
}
void glUseProgram(GLuint /*program*/) { RequireTestGraphicsStub(); }
void glVertexAttribPointer(GLuint /*index*/, GLint /*size*/, GLenum /*type*/,
GLboolean /*normalized*/, GLsizei /*stride*/,
const void* /*pointer*/) {
void glUseProgram(GLuint) { RequireTestGraphicsStub(); }
void glVertexAttribPointer(GLuint, GLint, GLenum,
GLboolean, GLsizei,
const void*) {
RequireTestGraphicsStub();
}
+2 -3
View File
@@ -1,8 +1,7 @@
// Copyright 2026 Mocktail Project Authors
// Apache 2.0 License
//
// stubs/liblog_stub.cc — Stub for Android liblog.so.
// Redirects __android_log_print et al. to stderr on the host.
// Redirects Android logging calls to stderr on the host.
#include <atomic>
#include <cstdarg>
@@ -95,7 +94,7 @@ void __android_log_assert(const char* cond, const char* tag,
__builtin_trap();
}
int __android_log_buf_write(int /*bufID*/, int prio, const char* tag,
int __android_log_buf_write(int, int prio, const char* tag,
const char* text) {
return __android_log_write(prio, tag, text);
}
+39 -43
View File
@@ -1,8 +1,7 @@
// Copyright 2026 Mocktail Project Authors
// Apache 2.0 License
//
// stubs/libmediandk_stub.cc — Stub for Android libmediandk.so.
// AMediaCodec / AMediaFormat are no-ops; Roblox uses them for video decoding.
// Minimal libmediandk shim for video APIs imported by Roblox.
#include <cstdint>
#include <cstdlib>
@@ -14,7 +13,6 @@ struct AMediaFormat {
char mime[64];
};
// Forward-declared Android media status type.
using media_status_t = int32_t;
static constexpr media_status_t AMEDIA_OK = 0;
static constexpr media_status_t AMEDIA_ERROR_UNKNOWN = -1;
@@ -33,71 +31,69 @@ const char* AMEDIAFORMAT_KEY_SAMPLE_RATE = "sample-rate";
const char* AMEDIAFORMAT_KEY_I_FRAME_INTERVAL = "i-frame-interval";
const char* AMEDIAFORMAT_KEY_STRIDE = "stride";
// AMediaFormat
AMediaFormat* AMediaFormat_new() {
return static_cast<AMediaFormat*>(calloc(1, sizeof(AMediaFormat)));
}
void AMediaFormat_delete(AMediaFormat* fmt) { free(fmt); }
bool AMediaFormat_getInt32(AMediaFormat* /*f*/, const char* /*k*/, int32_t* v) {
bool AMediaFormat_getInt32(AMediaFormat*, const char*, int32_t* v) {
if (v) *v = 0;
return false;
}
bool AMediaFormat_getBuffer(AMediaFormat* /*f*/, const char* /*k*/,
void** /*d*/, size_t* /*s*/) { return false; }
void AMediaFormat_setInt32(AMediaFormat* /*f*/, const char* /*k*/,
int32_t /*v*/) {}
void AMediaFormat_setFloat(AMediaFormat* /*f*/, const char* /*k*/,
float /*v*/) {}
void AMediaFormat_setString(AMediaFormat* /*f*/, const char* k,
bool AMediaFormat_getBuffer(AMediaFormat*, const char*,
void**, size_t*) { return false; }
void AMediaFormat_setInt32(AMediaFormat*, const char*,
int32_t) {}
void AMediaFormat_setFloat(AMediaFormat*, const char*,
float) {}
void AMediaFormat_setString(AMediaFormat*, const char* k,
const char* v) {
(void)k; (void)v;
}
void AMediaFormat_setBuffer(AMediaFormat* /*f*/, const char* /*k*/,
const void* /*d*/, size_t /*s*/) {}
const char* AMediaFormat_toString(AMediaFormat* /*f*/) { return "{}"; }
void AMediaFormat_setBuffer(AMediaFormat*, const char*,
const void*, size_t) {}
const char* AMediaFormat_toString(AMediaFormat*) { return "{}"; }
// AMediaCodec
AMediaCodec* AMediaCodec_createDecoderByType(const char* /*mime*/) {
AMediaCodec* AMediaCodec_createDecoderByType(const char*) {
return nullptr;
}
AMediaCodec* AMediaCodec_createEncoderByType(const char* /*mime*/) {
AMediaCodec* AMediaCodec_createEncoderByType(const char*) {
return nullptr;
}
media_status_t AMediaCodec_delete(AMediaCodec* /*codec*/) {
media_status_t AMediaCodec_delete(AMediaCodec*) {
return AMEDIA_OK;
}
media_status_t AMediaCodec_configure(AMediaCodec* /*codec*/,
const AMediaFormat* /*format*/,
void* /*surface*/, void* /*crypto*/,
uint32_t /*flags*/) {
media_status_t AMediaCodec_configure(AMediaCodec*,
const AMediaFormat*,
void*, void*,
uint32_t) {
return AMEDIA_ERROR_UNKNOWN;
}
media_status_t AMediaCodec_start(AMediaCodec* /*codec*/) {
media_status_t AMediaCodec_start(AMediaCodec*) {
return AMEDIA_ERROR_UNKNOWN;
}
media_status_t AMediaCodec_stop(AMediaCodec* /*codec*/) { return AMEDIA_OK; }
media_status_t AMediaCodec_flush(AMediaCodec* /*codec*/) { return AMEDIA_OK; }
uint8_t* AMediaCodec_getInputBuffer(AMediaCodec* /*codec*/, size_t /*idx*/,
size_t* /*sz*/) { return nullptr; }
uint8_t* AMediaCodec_getOutputBuffer(AMediaCodec* /*codec*/, size_t /*idx*/,
size_t* /*sz*/) { return nullptr; }
ssize_t AMediaCodec_dequeueInputBuffer(AMediaCodec* /*codec*/,
int64_t /*timeoutUs*/) { return -1; }
ssize_t AMediaCodec_dequeueOutputBuffer(AMediaCodec* /*codec*/,
void* /*info*/,
int64_t /*timeoutUs*/) { return -1; }
media_status_t AMediaCodec_queueInputBuffer(AMediaCodec* /*codec*/,
size_t /*idx*/, size_t /*offset*/,
size_t /*size*/, uint64_t /*pts*/,
uint32_t /*flags*/) {
media_status_t AMediaCodec_stop(AMediaCodec*) { return AMEDIA_OK; }
media_status_t AMediaCodec_flush(AMediaCodec*) { return AMEDIA_OK; }
uint8_t* AMediaCodec_getInputBuffer(AMediaCodec*, size_t,
size_t*) { return nullptr; }
uint8_t* AMediaCodec_getOutputBuffer(AMediaCodec*, size_t,
size_t*) { return nullptr; }
ssize_t AMediaCodec_dequeueInputBuffer(AMediaCodec*,
int64_t) { return -1; }
ssize_t AMediaCodec_dequeueOutputBuffer(AMediaCodec*,
void*,
int64_t) { return -1; }
media_status_t AMediaCodec_queueInputBuffer(AMediaCodec*,
size_t, size_t,
size_t, uint64_t,
uint32_t) {
return AMEDIA_ERROR_UNKNOWN;
}
media_status_t AMediaCodec_releaseOutputBuffer(AMediaCodec* /*codec*/,
size_t /*idx*/,
bool /*render*/) {
media_status_t AMediaCodec_releaseOutputBuffer(AMediaCodec*,
size_t,
bool) {
return AMEDIA_OK;
}
AMediaFormat* AMediaCodec_getOutputFormat(AMediaCodec* /*codec*/) {
AMediaFormat* AMediaCodec_getOutputFormat(AMediaCodec*) {
return AMediaFormat_new();
}
+165 -171
View File
@@ -1,12 +1,8 @@
// Copyright 2026 Mocktail Project Authors
// Apache 2.0 License
//
// stubs/libopensl_stub.cc — test fixture for libOpenSLES.so/libOpenMAXAL.so.
//
// Production audio must use the SDL-backed Mocktail audio foundation. This
// synthetic object graph is available only when the exact opt-in
// MOCKTAIL_ENABLE_TEST_AUDIO_STUBS=1 is present; otherwise engine creation
// fails closed. Audio capture is unsupported in both modes.
// Test fixture for libOpenSLES.so and libOpenMAXAL.so. Engine creation requires
// MOCKTAIL_ENABLE_TEST_AUDIO_STUBS=1; audio capture is not implemented.
#include <cstdint>
#include <cstdlib>
@@ -239,37 +235,37 @@ SLresult SuccessObject(SLObjectItf* object, SLObjectItf fake_object) {
return SL_RESULT_SUCCESS;
}
void ObjectDestroy(SLObjectItf /*self*/) {}
void ObjectDestroy(SLObjectItf) {}
SLresult ObjectRealize(SLObjectItf /*self*/, SLboolean /*async*/) {
SLresult ObjectRealize(SLObjectItf, SLboolean) {
return SL_RESULT_SUCCESS;
}
SLresult ObjectResume(SLObjectItf /*self*/, SLboolean /*async*/) {
SLresult ObjectResume(SLObjectItf, SLboolean) {
return SL_RESULT_SUCCESS;
}
SLresult ObjectGetState(SLObjectItf /*self*/, SLuint32* state) {
SLresult ObjectGetState(SLObjectItf, SLuint32* state) {
if (state) {
*state = SL_OBJECT_STATE_REALIZED;
}
return SL_RESULT_SUCCESS;
}
SLresult ObjectRegisterCallback(SLObjectItf /*self*/,
SLObjectCallback /*callback*/,
void* /*context*/) {
SLresult ObjectRegisterCallback(SLObjectItf,
SLObjectCallback,
void*) {
return SL_RESULT_SUCCESS;
}
void ObjectAbortAsyncOperation(SLObjectItf /*self*/) {}
void ObjectAbortAsyncOperation(SLObjectItf) {}
SLresult ObjectSetPriority(SLObjectItf /*self*/, SLint32 /*priority*/,
SLboolean /*preemptable*/) {
SLresult ObjectSetPriority(SLObjectItf, SLint32,
SLboolean) {
return SL_RESULT_SUCCESS;
}
SLresult ObjectGetPriority(SLObjectItf /*self*/, SLint32* priority,
SLresult ObjectGetPriority(SLObjectItf, SLint32* priority,
SLboolean* preemptable) {
if (priority) {
*priority = 0;
@@ -280,20 +276,20 @@ SLresult ObjectGetPriority(SLObjectItf /*self*/, SLint32* priority,
return SL_RESULT_SUCCESS;
}
SLresult ObjectSetLossOfControlInterfaces(SLObjectItf /*self*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
SLboolean /*enabled*/) {
SLresult ObjectSetLossOfControlInterfaces(SLObjectItf,
SLuint32,
const SLInterfaceID*,
SLboolean) {
return SL_RESULT_SUCCESS;
}
SLresult ObjectGetInterface(SLObjectItf self, SLInterfaceID iid,
void* interface_out);
SLresult CreateUnsupportedObject(SLEngineItf /*self*/, SLObjectItf* object,
SLuint32 /*device_id*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/);
SLresult CreateUnsupportedObject(SLEngineItf, SLObjectItf* object,
SLuint32,
SLuint32,
const SLInterfaceID*,
const SLboolean*);
SLresult CreateAudioPlayer(SLEngineItf self, SLObjectItf* player,
const void* source, const void* sink,
SLuint32 num_interfaces, const SLInterfaceID* ids,
@@ -306,46 +302,46 @@ SLresult CreateOutputMix(SLEngineItf self, SLObjectItf* mix,
SLuint32 num_interfaces, const SLInterfaceID* ids,
const SLboolean* required);
SLresult CreateMidiPlayer(SLEngineItf /*self*/, SLObjectItf* player,
const void* /*source*/, const void* /*sink*/,
const void* /*bank_source*/,
const void* /*bank_sink*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/);
SLresult CreateListener(SLEngineItf /*self*/, SLObjectItf* listener,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/);
SLresult Create3DGroup(SLEngineItf /*self*/, SLObjectItf* group,
SLuint32 /*num_interfaces*/, const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/);
SLresult CreateMetadataExtractor(SLEngineItf /*self*/, SLObjectItf* extractor,
const void* /*source*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/);
SLresult CreateExtensionObject(SLEngineItf /*self*/, SLObjectItf* object,
void* /*parameters*/, SLuint32 /*object_id*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/);
SLresult QueryNumSupportedInterfaces(SLEngineItf /*self*/,
SLuint32 /*object_id*/, SLuint32* count);
SLresult QuerySupportedInterfaces(SLEngineItf /*self*/, SLuint32 /*object_id*/,
SLuint32 /*index*/, SLInterfaceID* iid);
SLresult QueryNumSupportedExtensions(SLEngineItf /*self*/, SLuint32* count);
SLresult QuerySupportedExtension(SLEngineItf /*self*/, SLuint32 /*index*/,
SLresult CreateMidiPlayer(SLEngineItf, SLObjectItf* player,
const void*, const void*,
const void*,
const void*,
SLuint32,
const SLInterfaceID*,
const SLboolean*);
SLresult CreateListener(SLEngineItf, SLObjectItf* listener,
SLuint32,
const SLInterfaceID*,
const SLboolean*);
SLresult Create3DGroup(SLEngineItf, SLObjectItf* group,
SLuint32, const SLInterfaceID*,
const SLboolean*);
SLresult CreateMetadataExtractor(SLEngineItf, SLObjectItf* extractor,
const void*,
SLuint32,
const SLInterfaceID*,
const SLboolean*);
SLresult CreateExtensionObject(SLEngineItf, SLObjectItf* object,
void*, SLuint32,
SLuint32,
const SLInterfaceID*,
const SLboolean*);
SLresult QueryNumSupportedInterfaces(SLEngineItf,
SLuint32, SLuint32* count);
SLresult QuerySupportedInterfaces(SLEngineItf, SLuint32,
SLuint32, SLInterfaceID* iid);
SLresult QueryNumSupportedExtensions(SLEngineItf, SLuint32* count);
SLresult QuerySupportedExtension(SLEngineItf, SLuint32,
char* name, SLint32* name_length);
SLresult IsExtensionSupported(SLEngineItf /*self*/, const char* /*name*/,
SLresult IsExtensionSupported(SLEngineItf, const char*,
SLboolean* supported);
SLresult PlaySetState(SLPlayItf /*self*/, SLuint32 state) {
SLresult PlaySetState(SLPlayItf, SLuint32 state) {
g_play_state = state;
return SL_RESULT_SUCCESS;
}
SLresult PlayGetState(SLPlayItf /*self*/, SLuint32* state) {
SLresult PlayGetState(SLPlayItf, SLuint32* state) {
if (state) {
*state = g_play_state;
}
@@ -359,115 +355,115 @@ SLresult ReturnZeroU32(SLuint32* value) {
return SL_RESULT_SUCCESS;
}
SLresult PlayGetDuration(SLPlayItf /*self*/, SLuint32* duration) {
SLresult PlayGetDuration(SLPlayItf, SLuint32* duration) {
return ReturnZeroU32(duration);
}
SLresult PlayGetPosition(SLPlayItf /*self*/, SLuint32* position) {
SLresult PlayGetPosition(SLPlayItf, SLuint32* position) {
return ReturnZeroU32(position);
}
SLresult PlayRegisterCallback(SLPlayItf /*self*/, SLPlayCallback /*callback*/,
void* /*context*/) {
SLresult PlayRegisterCallback(SLPlayItf, SLPlayCallback,
void*) {
return SL_RESULT_SUCCESS;
}
SLresult PlaySetEventsMask(SLPlayItf /*self*/, SLuint32 /*mask*/) {
SLresult PlaySetEventsMask(SLPlayItf, SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult PlayGetEventsMask(SLPlayItf /*self*/, SLuint32* mask) {
SLresult PlayGetEventsMask(SLPlayItf, SLuint32* mask) {
return ReturnZeroU32(mask);
}
SLresult PlaySetMarkerPosition(SLPlayItf /*self*/, SLuint32 /*position*/) {
SLresult PlaySetMarkerPosition(SLPlayItf, SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult PlayClearMarkerPosition(SLPlayItf /*self*/) {
SLresult PlayClearMarkerPosition(SLPlayItf) {
return SL_RESULT_SUCCESS;
}
SLresult PlayGetMarkerPosition(SLPlayItf /*self*/, SLuint32* position) {
SLresult PlayGetMarkerPosition(SLPlayItf, SLuint32* position) {
return ReturnZeroU32(position);
}
SLresult PlaySetPositionUpdatePeriod(SLPlayItf /*self*/, SLuint32 /*period*/) {
SLresult PlaySetPositionUpdatePeriod(SLPlayItf, SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult PlayGetPositionUpdatePeriod(SLPlayItf /*self*/, SLuint32* period) {
SLresult PlayGetPositionUpdatePeriod(SLPlayItf, SLuint32* period) {
return ReturnZeroU32(period);
}
SLresult RecordSetState(SLRecordItf /*self*/, SLuint32 state) {
SLresult RecordSetState(SLRecordItf, SLuint32 state) {
g_record_state = state;
return SL_RESULT_SUCCESS;
}
SLresult RecordGetState(SLRecordItf /*self*/, SLuint32* state) {
SLresult RecordGetState(SLRecordItf, SLuint32* state) {
if (state) {
*state = g_record_state;
}
return SL_RESULT_SUCCESS;
}
SLresult RecordSetDurationLimit(SLRecordItf /*self*/,
SLuint32 /*milliseconds*/) {
SLresult RecordSetDurationLimit(SLRecordItf,
SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult RecordGetPosition(SLRecordItf /*self*/, SLuint32* position) {
SLresult RecordGetPosition(SLRecordItf, SLuint32* position) {
return ReturnZeroU32(position);
}
SLresult RecordRegisterCallback(SLRecordItf /*self*/,
SLRecordCallback /*callback*/,
void* /*context*/) {
SLresult RecordRegisterCallback(SLRecordItf,
SLRecordCallback,
void*) {
return SL_RESULT_SUCCESS;
}
SLresult RecordSetEventsMask(SLRecordItf /*self*/, SLuint32 /*mask*/) {
SLresult RecordSetEventsMask(SLRecordItf, SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult RecordGetEventsMask(SLRecordItf /*self*/, SLuint32* mask) {
SLresult RecordGetEventsMask(SLRecordItf, SLuint32* mask) {
return ReturnZeroU32(mask);
}
SLresult RecordSetMarkerPosition(SLRecordItf /*self*/, SLuint32 /*position*/) {
SLresult RecordSetMarkerPosition(SLRecordItf, SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult RecordClearMarkerPosition(SLRecordItf /*self*/) {
SLresult RecordClearMarkerPosition(SLRecordItf) {
return SL_RESULT_SUCCESS;
}
SLresult RecordGetMarkerPosition(SLRecordItf /*self*/, SLuint32* position) {
SLresult RecordGetMarkerPosition(SLRecordItf, SLuint32* position) {
return ReturnZeroU32(position);
}
SLresult RecordSetPositionUpdatePeriod(SLRecordItf /*self*/,
SLuint32 /*period*/) {
SLresult RecordSetPositionUpdatePeriod(SLRecordItf,
SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult RecordGetPositionUpdatePeriod(SLRecordItf /*self*/, SLuint32* period) {
SLresult RecordGetPositionUpdatePeriod(SLRecordItf, SLuint32* period) {
return ReturnZeroU32(period);
}
SLresult BufferQueueEnqueue(SLBufferQueueItf /*self*/, const void* /*buffer*/,
SLuint32 /*size*/) {
SLresult BufferQueueEnqueue(SLBufferQueueItf, const void*,
SLuint32) {
++g_buffer_queue_state.count;
return SL_RESULT_SUCCESS;
}
SLresult BufferQueueClear(SLBufferQueueItf /*self*/) {
SLresult BufferQueueClear(SLBufferQueueItf) {
g_buffer_queue_state = {};
return SL_RESULT_SUCCESS;
}
SLresult BufferQueueGetState(SLBufferQueueItf /*self*/,
SLresult BufferQueueGetState(SLBufferQueueItf,
SLBufferQueueState* state) {
if (state) {
*state = g_buffer_queue_state;
@@ -475,7 +471,7 @@ SLresult BufferQueueGetState(SLBufferQueueItf /*self*/,
return SL_RESULT_SUCCESS;
}
SLresult BufferQueueRegisterCallback(SLBufferQueueItf /*self*/,
SLresult BufferQueueRegisterCallback(SLBufferQueueItf,
SLBufferQueueCallback callback,
void* context) {
g_buffer_queue_callback = callback;
@@ -483,18 +479,18 @@ SLresult BufferQueueRegisterCallback(SLBufferQueueItf /*self*/,
return SL_RESULT_SUCCESS;
}
SLresult SimpleBufferQueueEnqueue(SLAndroidSimpleBufferQueueItf /*self*/,
const void* /*buffer*/, SLuint32 /*size*/) {
SLresult SimpleBufferQueueEnqueue(SLAndroidSimpleBufferQueueItf,
const void*, SLuint32) {
++g_simple_queue_state.count;
return SL_RESULT_SUCCESS;
}
SLresult SimpleBufferQueueClear(SLAndroidSimpleBufferQueueItf /*self*/) {
SLresult SimpleBufferQueueClear(SLAndroidSimpleBufferQueueItf) {
g_simple_queue_state = {};
return SL_RESULT_SUCCESS;
}
SLresult SimpleBufferQueueGetState(SLAndroidSimpleBufferQueueItf /*self*/,
SLresult SimpleBufferQueueGetState(SLAndroidSimpleBufferQueueItf,
SLBufferQueueState* state) {
if (state) {
*state = g_simple_queue_state;
@@ -503,40 +499,40 @@ SLresult SimpleBufferQueueGetState(SLAndroidSimpleBufferQueueItf /*self*/,
}
SLresult SimpleBufferQueueRegisterCallback(
SLAndroidSimpleBufferQueueItf /*self*/,
SLAndroidSimpleBufferQueueItf,
SLAndroidSimpleBufferQueueCallback callback, void* context) {
g_simple_queue_callback = callback;
g_simple_queue_context = context;
return SL_RESULT_SUCCESS;
}
SLresult VolumeSetLevel(SLVolumeItf /*self*/, SLint32 /*level*/) {
SLresult VolumeSetLevel(SLVolumeItf, SLint32) {
return SL_RESULT_SUCCESS;
}
SLresult VolumeGetLevel(SLVolumeItf /*self*/, SLint32* level) {
SLresult VolumeGetLevel(SLVolumeItf, SLint32* level) {
if (level) {
*level = 0;
}
return SL_RESULT_SUCCESS;
}
SLresult VolumeSetMute(SLVolumeItf /*self*/, SLboolean /*mute*/) {
SLresult VolumeSetMute(SLVolumeItf, SLboolean) {
return SL_RESULT_SUCCESS;
}
SLresult VolumeGetMute(SLVolumeItf /*self*/, SLboolean* mute) {
SLresult VolumeGetMute(SLVolumeItf, SLboolean* mute) {
if (mute) {
*mute = 0;
}
return SL_RESULT_SUCCESS;
}
SLresult VolumeEnableStereoPosition(SLVolumeItf /*self*/, SLboolean /*enable*/) {
SLresult VolumeEnableStereoPosition(SLVolumeItf, SLboolean) {
return SL_RESULT_SUCCESS;
}
SLresult VolumeIsStereoPositionEnabled(SLVolumeItf /*self*/,
SLresult VolumeIsStereoPositionEnabled(SLVolumeItf,
SLboolean* enabled) {
if (enabled) {
*enabled = 0;
@@ -544,23 +540,23 @@ SLresult VolumeIsStereoPositionEnabled(SLVolumeItf /*self*/,
return SL_RESULT_SUCCESS;
}
SLresult VolumeSetStereoPosition(SLVolumeItf /*self*/, SLint32 /*position*/) {
SLresult VolumeSetStereoPosition(SLVolumeItf, SLint32) {
return SL_RESULT_SUCCESS;
}
SLresult VolumeGetStereoPosition(SLVolumeItf /*self*/, SLint32* position) {
SLresult VolumeGetStereoPosition(SLVolumeItf, SLint32* position) {
if (position) {
*position = 0;
}
return SL_RESULT_SUCCESS;
}
SLresult ConfigSet(SLAndroidConfigurationItf /*self*/, const char* /*key*/,
const void* /*value*/, SLuint32 /*value_size*/) {
SLresult ConfigSet(SLAndroidConfigurationItf, const char*,
const void*, SLuint32) {
return SL_RESULT_SUCCESS;
}
SLresult ConfigGet(SLAndroidConfigurationItf /*self*/, const char* /*key*/,
SLresult ConfigGet(SLAndroidConfigurationItf, const char*,
SLuint32* value_size, void* value) {
if (value_size) {
if (value && *value_size > 0) {
@@ -709,7 +705,7 @@ void* SL_IID_VOLUME = reinterpret_cast<void*>(0x1007);
namespace {
SLresult ObjectGetInterface(SLObjectItf /*self*/, SLInterfaceID iid,
SLresult ObjectGetInterface(SLObjectItf, SLInterfaceID iid,
void* interface_out) {
if (!interface_out) {
return SL_RESULT_SUCCESS;
@@ -749,107 +745,107 @@ SLresult ObjectGetInterface(SLObjectItf /*self*/, SLInterfaceID iid,
return SL_RESULT_FEATURE_UNSUPPORTED;
}
SLresult CreateUnsupportedObject(SLEngineItf /*self*/, SLObjectItf* object,
SLuint32 /*device_id*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateUnsupportedObject(SLEngineItf, SLObjectItf* object,
SLuint32,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(object, GenericObject());
}
SLresult CreateAudioPlayer(SLEngineItf /*self*/, SLObjectItf* player,
const void* /*source*/, const void* /*sink*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateAudioPlayer(SLEngineItf, SLObjectItf* player,
const void*, const void*,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
g_play_state = SL_PLAYSTATE_STOPPED;
g_buffer_queue_state = {};
g_simple_queue_state = {};
return SuccessObject(player, PlayerObject());
}
SLresult CreateAudioRecorder(SLEngineItf /*self*/, SLObjectItf* recorder,
const void* /*source*/, const void* /*sink*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateAudioRecorder(SLEngineItf, SLObjectItf* recorder,
const void*, const void*,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
if (recorder != nullptr) {
*recorder = nullptr;
}
return SL_RESULT_FEATURE_UNSUPPORTED;
}
SLresult CreateMidiPlayer(SLEngineItf /*self*/, SLObjectItf* player,
const void* /*source*/, const void* /*sink*/,
const void* /*bank_source*/,
const void* /*bank_sink*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateMidiPlayer(SLEngineItf, SLObjectItf* player,
const void*, const void*,
const void*,
const void*,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(player, PlayerObject());
}
SLresult CreateListener(SLEngineItf /*self*/, SLObjectItf* listener,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateListener(SLEngineItf, SLObjectItf* listener,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(listener, GenericObject());
}
SLresult Create3DGroup(SLEngineItf /*self*/, SLObjectItf* group,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult Create3DGroup(SLEngineItf, SLObjectItf* group,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(group, GenericObject());
}
SLresult CreateOutputMix(SLEngineItf /*self*/, SLObjectItf* mix,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateOutputMix(SLEngineItf, SLObjectItf* mix,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(mix, OutputMixObject());
}
SLresult CreateMetadataExtractor(SLEngineItf /*self*/, SLObjectItf* extractor,
const void* /*source*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateMetadataExtractor(SLEngineItf, SLObjectItf* extractor,
const void*,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(extractor, GenericObject());
}
SLresult CreateExtensionObject(SLEngineItf /*self*/, SLObjectItf* object,
void* /*parameters*/, SLuint32 /*object_id*/,
SLuint32 /*num_interfaces*/,
const SLInterfaceID* /*ids*/,
const SLboolean* /*required*/) {
SLresult CreateExtensionObject(SLEngineItf, SLObjectItf* object,
void*, SLuint32,
SLuint32,
const SLInterfaceID*,
const SLboolean*) {
return SuccessObject(object, GenericObject());
}
SLresult QueryNumSupportedInterfaces(SLEngineItf /*self*/,
SLuint32 /*object_id*/, SLuint32* count) {
SLresult QueryNumSupportedInterfaces(SLEngineItf,
SLuint32, SLuint32* count) {
if (count) {
*count = 0;
}
return SL_RESULT_SUCCESS;
}
SLresult QuerySupportedInterfaces(SLEngineItf /*self*/, SLuint32 /*object_id*/,
SLuint32 /*index*/, SLInterfaceID* iid) {
SLresult QuerySupportedInterfaces(SLEngineItf, SLuint32,
SLuint32, SLInterfaceID* iid) {
if (iid) {
*iid = nullptr;
}
return SL_RESULT_FEATURE_UNSUPPORTED;
}
SLresult QueryNumSupportedExtensions(SLEngineItf /*self*/, SLuint32* count) {
SLresult QueryNumSupportedExtensions(SLEngineItf, SLuint32* count) {
if (count) {
*count = 0;
}
return SL_RESULT_SUCCESS;
}
SLresult QuerySupportedExtension(SLEngineItf /*self*/, SLuint32 /*index*/,
SLresult QuerySupportedExtension(SLEngineItf, SLuint32,
char* name, SLint32* name_length) {
if (name_length) {
*name_length = 0;
@@ -860,7 +856,7 @@ SLresult QuerySupportedExtension(SLEngineItf /*self*/, SLuint32 /*index*/,
return SL_RESULT_FEATURE_UNSUPPORTED;
}
SLresult IsExtensionSupported(SLEngineItf /*self*/, const char* /*name*/,
SLresult IsExtensionSupported(SLEngineItf, const char*,
SLboolean* supported) {
if (supported) {
*supported = 0;
@@ -872,12 +868,11 @@ SLresult IsExtensionSupported(SLEngineItf /*self*/, const char* /*name*/,
extern "C" {
// Test-only OpenSL ES entry point. Production fails closed.
SLresult slCreateEngine(void** pEngine, uint32_t /*numOptions*/,
const void* /*pEngineOptions*/,
uint32_t /*numInterfaces*/,
const void* /*pInterfaceIds*/,
const void* /*pInterfaceRequired*/) {
SLresult slCreateEngine(void** pEngine, uint32_t,
const void*,
uint32_t,
const void*,
const void*) {
if (pEngine == nullptr) {
return SL_RESULT_PARAMETER_INVALID;
}
@@ -890,12 +885,11 @@ SLresult slCreateEngine(void** pEngine, uint32_t /*numOptions*/,
return SL_RESULT_SUCCESS;
}
// Test-only OpenMAX AL entry point. Production fails closed.
SLresult alCreateEngine(void** pEngine, uint32_t /*numOptions*/,
const void* /*pEngineOptions*/,
uint32_t /*numInterfaces*/,
const void* /*pInterfaceIds*/,
const void* /*pInterfaceRequired*/) {
SLresult alCreateEngine(void** pEngine, uint32_t,
const void*,
uint32_t,
const void*,
const void*) {
if (pEngine == nullptr) {
return SL_RESULT_PARAMETER_INVALID;
}
+15 -15
View File
@@ -35,8 +35,8 @@ bool TestVulkanStubEnabled() {
extern "C" {
VkResult vkCreateInstance(const void* /*create_info*/,
const VkAllocationCallbacks* /*allocator*/,
VkResult vkCreateInstance(const void*,
const VkAllocationCallbacks*,
VkInstance* instance) {
if (!TestVulkanStubEnabled()) {
if (instance != nullptr) {
@@ -50,12 +50,12 @@ VkResult vkCreateInstance(const void* /*create_info*/,
return kVkSuccess;
}
void vkDestroyInstance(VkInstance /*instance*/,
const VkAllocationCallbacks* /*allocator*/) {}
void vkDestroyInstance(VkInstance,
const VkAllocationCallbacks*) {}
VkResult vkCreateAndroidSurfaceKHR(VkInstance /*instance*/,
const void* /*create_info*/,
const VkAllocationCallbacks* /*allocator*/,
VkResult vkCreateAndroidSurfaceKHR(VkInstance,
const void*,
const VkAllocationCallbacks*,
VkSurfaceKHR* surface) {
if (!TestVulkanStubEnabled()) {
if (surface != nullptr) {
@@ -69,12 +69,12 @@ VkResult vkCreateAndroidSurfaceKHR(VkInstance /*instance*/,
return kVkSuccess;
}
void vkDestroySurfaceKHR(VkInstance /*instance*/, VkSurfaceKHR /*surface*/,
const VkAllocationCallbacks* /*allocator*/) {}
void vkDestroySurfaceKHR(VkInstance, VkSurfaceKHR,
const VkAllocationCallbacks*) {}
VkResult vkEnumerateInstanceExtensionProperties(const char* /*layer_name*/,
VkResult vkEnumerateInstanceExtensionProperties(const char*,
uint32_t* property_count,
void* /*properties*/) {
void*) {
if (property_count != nullptr) {
*property_count = 0;
}
@@ -82,19 +82,19 @@ VkResult vkEnumerateInstanceExtensionProperties(const char* /*layer_name*/,
}
VkResult vkEnumerateInstanceLayerProperties(uint32_t* property_count,
void* /*properties*/) {
void*) {
if (property_count != nullptr) {
*property_count = 0;
}
return kVkSuccess;
}
PFN_vkVoidFunction vkGetDeviceProcAddr(VkDevice /*device*/,
const char* /*name*/) {
PFN_vkVoidFunction vkGetDeviceProcAddr(VkDevice,
const char*) {
return nullptr;
}
PFN_vkVoidFunction vkGetInstanceProcAddr(VkInstance /*instance*/,
PFN_vkVoidFunction vkGetInstanceProcAddr(VkInstance,
const char* name) {
if (name == nullptr) {
return nullptr;
+35
View File
@@ -25,6 +25,12 @@ target_compile_definitions(runtime_config_file_test PRIVATE
MOCKTAIL_TEST_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
)
add_executable(discord_rpc_test discord_rpc_test.cc)
target_link_libraries(discord_rpc_test PRIVATE
Mocktail::Runtime
GTest::gtest_main
)
add_executable(native_update_helper_fixture native_update_helper_fixture.cc)
add_executable(native_progress_helper_fixture native_progress_helper_fixture.cc)
mocktail_apply_compile_options(native_update_helper_fixture)
@@ -98,6 +104,30 @@ target_link_libraries(game_mode_test PRIVATE
GTest::gtest_main
)
add_library(game_mode_namespace_fixture SHARED
game_mode_namespace_fixture.cc
)
set_target_properties(game_mode_namespace_fixture PROPERTIES
OUTPUT_NAME gamemode
VERSION 0.0.0
SOVERSION 0
LIBRARY_OUTPUT_DIRECTORY
"${CMAKE_CURRENT_BINARY_DIR}/game_mode_namespace_fixture"
)
target_link_libraries(game_mode_namespace_fixture PRIVATE ${CMAKE_DL_LIBS})
add_executable(game_mode_link_namespace_test
game_mode_link_namespace_test.cc
)
target_link_libraries(game_mode_link_namespace_test PRIVATE
Mocktail::Runtime
GTest::gtest_main
)
target_link_options(game_mode_link_namespace_test PRIVATE
-Wl,--export-dynamic
)
add_dependencies(game_mode_link_namespace_test game_mode_namespace_fixture)
add_executable(memory_limit_test memory_limit_test.cc)
target_link_libraries(memory_limit_test PRIVATE
Mocktail::Runtime
@@ -780,6 +810,10 @@ gtest_discover_tests(http_client_policy_test)
gtest_discover_tests(http_client_spin_guard_test)
gtest_discover_tests(crash_report_policy_test)
gtest_discover_tests(game_mode_test)
gtest_discover_tests(game_mode_link_namespace_test PROPERTIES
ENVIRONMENT
"LD_LIBRARY_PATH=${CMAKE_CURRENT_BINARY_DIR}/game_mode_namespace_fixture"
)
gtest_discover_tests(memory_limit_test)
gtest_discover_tests(runtime_paths_test)
gtest_discover_tests(single_instance_lock_test)
@@ -849,6 +883,7 @@ if(MOCKTAIL_ENABLE_UPSTREAM_JNIVM)
gtest_discover_tests(upstream_jnivm_integration_test)
endif()
gtest_discover_tests(libc_shim_test)
gtest_discover_tests(discord_rpc_test)
gtest_discover_tests(asset_manager_test)
gtest_discover_tests(android_native_window_test)
gtest_discover_tests(headless_graphics_test)
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more