Compare commits

...
26 Commits
Author SHA1 Message Date
vii b19bd8406f docs(changelog): release 0.3.2 2026-09-29 23:33:26 -05:00
vii cd46538eb8 docs(readme): document Flatpak build job settings 2026-09-29 23:27:33 -05:00
vii eca5f395eb update(flatpak): default local builds to 2 jobs 2026-09-29 23:25:03 -05:00
vii 26767e925c fix(webview): keep Roblox's WebViewProtocol alive so login challenges finish
Roblox's WebViewProtocol is a counted subsystem that Android's app start
holds for the process lifetime. Nightcap held no handle, so the protocol
and its handleJavascriptCallback subscriber were deleted after each use.
Page messages such as challengeCompleted then waited for the next Lua
WebView call, which came only at the 60 s challenge timeout, and login
failed.

Find the unexported getter through its call site in
initializeAndroidWebViewProtocol, check both functions against x86-64
and AArch64 code contracts, and hold one handle before native init.

The helper no longer closes a solved challenge before the host reads its
events. Roblox closes WebViewProtocol challenges itself, with a 3 s host
fallback; OPEN_CAPTCHA_VIEW windows close once success is forwarded.
Solved-challenge feature names now live in one shared header.
2026-09-29 22:09:27 -05:00
vii 01a0e40463 docs(changelog): add unreleased login, input and ARM64 entries 2026-09-29 18:24:25 -05:00
glook9001 a11e833e06 fix(webview): handle challengeCompleted event to acknowledge callback and close captcha window 2026-09-29 18:21:45 -05:00
vii b9bce36824 docs(roadmap): move first-load stutter to shipped 2026-09-29 17:36:41 -05:00
vii 14c8a55211 docs(github): adapt issue and PR templates for Nightcap 2026-09-29 17:36:41 -05:00
vii 5c5422fa02 fix(login): answer AccountProtocol device-integrity requests
Roblox login challenges asked for a Play Integrity token that nothing
answered, so the challenge sat out LuaApp's native timeout and looped.
A new optional bridge reports integrity as unsupported and answers token
requests at once with an empty token (TOKEN_PROVIDER_UNINITIALIZED).
2026-09-29 16:37:12 -05:00
vii 9b041ec2ac fix(webview): allow 5 s for helper startup and log its timing
Slow starts (cold Flatpak) could exceed the 3 s ready deadline and kill
login verification windows. Launch and timeout logs now report elapsed ms.
2026-09-29 16:05:01 -05:00
vii 5f83c4a64f docs(readme): center the logo, title, and badges 2026-09-26 04:07:01 -05:00
vii a9d8e3185e chore(merge): merge upstream komaruworld/mocktail main 2026-09-26 03:59:06 -05:00
vii 67a9f4db97 fix(input): advance the TextBox caret past trailing whitespace (#148)
The final cluster rect SDL_ttf reports stops at the last inked glyph, so a
caret at the end of a single-line field did not move when a trailing space
was typed. Use the laid-out width there; wrapped layouts keep the clamp
because their width is the widest line rather than the last one.
2026-09-25 18:40:23 +03:00
farwhile f07daccc82 fix(input): map middle click to MouseButton3 (#171) 2026-09-25 18:39:24 +03:00
珍惜andHttpAnimations 2eef4f966f Reject partial or overflowing user ids (#168)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-25 18:37:33 +03:00
vii 41003efb09 docs(changelog): record the private server join fix 2026-09-24 23:35:46 -05:00
vii 29a0f03a90 fix(webview): launch web Play buttons without waiting for navigation
Roblox defers a launchGame sent from the WebView until the Lua app next
navigates, so joining a private server stalled until a sidebar tab was
clicked. Parse launchGame in the runtime, queue it through the
experience launch pipeline, and close the WebView. Other commands still
reach Roblox.

Fixes #169
2026-09-24 23:02:48 -05:00
vii 5a27aa0366 fix(runtime): build engine pump rest on aarch64 hosts 2026-09-24 18:01:41 -05:00
vii c99a55dc54 chore(merge): merge upstream komaruworld/mocktail main
Brings in aarch64 host support (#152, #162, #167), natural scrolling (#163)
and the arm64 CI build job. Packaging, Pages and README keep the Nightcap
x86_64 side; upstream's aarch64 bundles are not published.
2026-09-24 17:44:28 -05:00
珍惜andHttpAnimations 30d50f15d1 Fix catalog unable to tell arm64 from x86_64 profiles for the same version. (#167)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-24 20:12:18 +03:00
komaruworld ab940acfb5 add arm64 ci with build
fog, update faq
2026-09-24 19:03:22 +03:00
farwhile 719b1fdd22 fix/respect host natural scrolling direction (#163) 2026-09-23 18:31:41 +03:00
珍惜andHttpAnimations 1fa2e116b3 Fix crashing on x86 hosts (#162)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-23 12:04:53 +03:00
vii 35359f3575 perf(etc2): carve upload staging out of shared blocks
Each ETC2 upload created and destroyed its own staging buffer on the render
thread. Uploads now take ranges of 16 MiB blocks, reused once every range
is released; four empty blocks are kept. Profile traces gain per-frame CPU
time counters and slices for staging and driver memory calls.
2026-09-22 21:31:58 -05:00
珍惜andHttpAnimations 40237fae72 arm 64 (#152)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-21 20:09:37 +03:00
vii ad6a3c3147 docs(readme): match the DEB filename that CPack produces 2026-09-20 13:06:45 -05:00
86 changed files with 3312 additions and 404 deletions

No files matched your search

+5 -5
View File
@@ -11,14 +11,14 @@ body:
value: |
Please include enough detail for someone else to reproduce the bug.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Use any language you are comfortable with.
Please write issues and pull requests yourself. Do not use AI-generated text.
Please write issues and pull requests yourself.
- type: input
id: version
attributes:
label: Mocktail version
label: Nightcap version
description: Provide the release version, package version, or commit hash.
placeholder: "For example: v0.4.0 or 1a2b3c4"
validations:
@@ -37,7 +37,7 @@ body:
id: installation
attributes:
label: Installation method
description: Tell us how Mocktail was installed or built.
description: Tell us how Nightcap was installed or built.
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
validations:
required: true
@@ -56,7 +56,7 @@ body:
label: Steps to reproduce
description: List the smallest reliable sequence of actions that triggers the problem.
placeholder: |
1. Start Mocktail with ...
1. Start Nightcap with ...
2. Open ...
3. Select ...
4. Observe ...
+5 -4
View File
@@ -1,5 +1,6 @@
# Modified by vii from komaruworld/mocktail. See README "About this fork".
name: Feature request
description: Suggest an improvement or a new capability for Mocktail
description: Suggest an improvement or a new capability for Nightcap
title: "[Feature]: "
labels:
- enhancement
@@ -10,9 +11,9 @@ body:
value: |
Describe what you want to change and why it would be useful.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Use any language you are comfortable with.
Please write issues and pull requests yourself. Do not use AI-generated text.
Please write issues and pull requests yourself.
- type: textarea
id: problem
@@ -26,7 +27,7 @@ body:
id: proposal
attributes:
label: Proposed solution
description: Describe how you would like Mocktail to behave.
description: Describe how you would like Nightcap to behave.
validations:
required: true
+2 -1
View File
@@ -1,3 +1,4 @@
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
Closes #
+50
View File
@@ -8,22 +8,32 @@ on:
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- '.gitmodules'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/**'
- 'scripts/**'
- 'src/**'
- 'stubs/**'
- 'tests/**'
- 'third_party/**'
pull_request:
paths:
- '.github/workflows/**'
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- '.gitmodules'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/**'
- 'scripts/**'
- 'src/**'
- 'stubs/**'
- 'tests/**'
- 'third_party/**'
workflow_dispatch:
permissions:
@@ -65,3 +75,43 @@ jobs:
bash tests/flatpak_packaging_test.sh .
bash tests/native_packaging_test.sh .
bash tests/anylinux_dependencies_test.sh .
native-build:
name: Native build (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-26.04
- arch: aarch64
runner: ubuntu-26.04-arm
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
- name: Install build dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
build-essential binutils cmake git ninja-build pkg-config lld \
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
libplacebo-dev libpng-dev zlib1g-dev
- name: Build and check native executables
shell: bash
run: |
test "$(uname -m)" = "${{ matrix.arch }}"
cmake -S . -B build-ci -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
cmake --build build-ci --parallel 4
./build-ci/mocktail --help
./build-ci/mocktail_updater status
+50 -1
View File
@@ -6,6 +6,54 @@ GitHub are taken from the matching section here.
## [Unreleased]
## [0.3.2] - 2026-09-29
### Added
- Performance recordings made with `--profile` now show how busy the CPU was
during each frame, which makes it easier to tell what caused a stutter.
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android
`arm64-v8a` client.
### Fixed
- When Roblox asks for a Google Play device check during login, Nightcap now
answers right away that it isn't supported. Before, the "Verifying you're
not a bot" screen waited about a minute for an answer and then sent you
back to the login screen.
- Solving a login challenge now closes its window. Before, Roblox's
"challenge completed" signal was ignored and the window stayed open.
Thanks to @glook9001.
- Login no longer stalls after you pass a verification check. Before, Roblox
only saw the page's "challenge completed" signal after a one-minute
timeout, so it gave up and the login failed. On a Roblox build Nightcap
can't handle this way, the log says "WebViewProtocol getter not
recognized" and web pages behave as before.
- Login check windows and other web pages get 5 seconds to start instead of
3, so a slow start no longer closes them. The log says how long each one
took to open.
- Less stutter while joining a game and while textures load in. In Blade
Ball, loading hitches dropped by about half, and the worst one went from
0.8 seconds to 0.55 seconds.
- Clicking Play on a private server now joins right away. Before, nothing
happened until you clicked a sidebar tab like Home.
- Middle click now reaches Roblox as the middle mouse button.
- Scrolling follows your system's natural scrolling setting.
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other
than the one making the call.
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles
for the same version.
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or
`MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID` is now ignored instead of being
partly read.
### Changed
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by
default instead of 4, so a local Flatpak build is less likely to run out
of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use more.
Thanks to @glook9001.
## [0.3.1] - 2026-09-20
### Added
@@ -190,7 +238,8 @@ First Nightcap release, forked from komaruworld/mocktail.
- The AppImage build no longer fails on stale checksums for the slimmed
Arch packages.
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.1...HEAD
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.2...HEAD
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
+10 -3
View File
@@ -10,7 +10,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 1.0.4
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
DESCRIPTION "Android Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -288,6 +288,7 @@ add_library(mocktail_runtime STATIC
src/runtime/roblox_game_session_native_adapter.cc
src/runtime/roblox_web_view_bridge.cc
src/runtime/roblox_permissions_bridge.cc
src/runtime/roblox_account_bridge.cc
src/runtime/payload_update_preflight.cc
src/runtime/process_launch_policy.cc
src/runtime/process_diagnostics.cc
@@ -385,7 +386,8 @@ add_library(mocktail_webview_helper_policy STATIC
src/webview/webview_helper_policy.cc
)
add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy)
target_include_directories(mocktail_webview_helper_policy PUBLIC src)
target_include_directories(mocktail_webview_helper_policy PUBLIC src
PRIVATE include)
target_link_libraries(mocktail_webview_helper_policy PUBLIC
PkgConfig::WEBKITGTK6
nlohmann_json::nlohmann_json
@@ -650,6 +652,11 @@ target_link_options(mocktail PRIVATE
)
# Native FreeBSD helper for Linuxulator's unsupported DF socket option.
# The helper is x86-64 assembly; other host architectures have nothing to
# assemble.
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$")
set(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER OFF)
endif()
option(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER
"Build the native FreeBSD Linuxulator socket helper" ON)
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER)
@@ -811,7 +818,7 @@ set(CPACK_PACKAGE_CONTACT "vii <coderdayton14@gmail.com>")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CoderDayton/nightcap")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}")
set(CPACK_PACKAGE_DESCRIPTION
"Runs the Android x86-64 Roblox client on Linux."
"Runs the Android Roblox client on Linux."
)
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")
set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
+12
View File
@@ -42,6 +42,18 @@ attach `latest.log`:
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
## Can I run multiple Roblox instances?
No. Nightcap won't add multi-instance launching. It would mean working around
Roblox's restrictions on running several clients. The [Roblox Terms of Use](https://en.help.roblox.com/hc/en-us/articles/115004647846-Roblox-Terms-of-Use)
prohibit bypassing technical protections, and violating the terms can get your
account suspended.
Bloxstrap brought the option back in [v2.9.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.9.0)
with a "use at your own risk" warning, then removed it in
[v2.10.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.10.0)
after Roblox added measures against running multiple clients.
## Can I play in VR?
VR is experimental and lives on upstream's `vr` branch, which does not
+3 -3
View File
@@ -21,7 +21,7 @@ ANYLINUX_APPIMAGETOOL ?= appimagetool
PREFIX ?= /usr
FLATPAK_BUILD_DIR ?= build-flatpak
FLATPAK_MANIFEST ?= packaging/flatpak/io.github.CoderDayton.nightcap.json
FLATPAK_JOBS ?= 4
FLATPAK_JOBS ?= 2
define build_native_runtime
@git submodule update --init --recursive
@@ -130,8 +130,8 @@ auto-run: ## Build/run loop with crash summaries
run-gles: build ## Run strict system EGL/OpenGL ES 3 without Vulkan fallback
@$(BINARY) --graphics opengl
run-angle: ## Run with ANGLE/Vulkan compatibility backend
@MOCKTAIL_GRAPHICS_BACKEND=angle-vulkan ./scripts/run_sober.sh
run-angle: build ## Run with ANGLE/Vulkan compatibility backend
@$(BINARY) --graphics angle-vulkan
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
@./scripts/update_roblox_payload.sh
+16 -6
View File
@@ -1,9 +1,14 @@
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
# Nightcap
[![CI](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg)](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml)
[![Stars](https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github)](https://github.com/CoderDayton/nightcap/stargazers)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
<div align="center">
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
<h1 style="margin-top: 5px;">
Nightcap
<br>
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
</h1>
</div>
Nightcap is Mocktail, tuned for playing on a real PC. It runs the Android
`x86_64` Roblox client on Linux, including a Linux userspace hosted by
@@ -68,7 +73,7 @@ Each release also carries a DEB, an RPM, and a pacman package, with a
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
```bash
sudo apt install ./nightcap_<version>-1_amd64.deb
sudo apt install ./nightcap_<version>_amd64.deb
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
```
@@ -192,6 +197,11 @@ Use `sudo make install` instead to install system-wide under `/usr`. To make
Roblox website links open the build tree copy without installing, run
`make register-url-handler`.
To build and install the Flatpak from source, install `flatpak-builder` and run
`make flatpak`. It builds with 2 parallel jobs to keep memory use low. Pass
`FLATPAK_JOBS=N` to `make flatpak`, or set `MOCKTAIL_FLATPAK_JOBS=N` for
`scripts/build_flatpak.sh`, to change that.
To run the same checks as CI before each commit and push, install
[lefthook](https://github.com/evilmartians/lefthook) and run once:
+17
View File
@@ -4,6 +4,7 @@
{
"version_name": "2.721.1108",
"version_code": 2350,
"abi": "x86_64",
"elf_build_id": "50e1b0abd123350e794226062fe3a1ef360c5f0d",
"status": "legacy-researched",
"default_allowed": false,
@@ -15,6 +16,7 @@
{
"version_name": "2.725.1142",
"version_code": 2546,
"abi": "x86_64",
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
"status": "supported",
"default_allowed": true,
@@ -35,6 +37,7 @@
{
"version_name": "2.727.1199",
"version_code": 2628,
"abi": "x86_64",
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
"status": "supported",
"default_allowed": true,
@@ -46,6 +49,7 @@
{
"version_name": "2.734.917",
"version_code": 2908,
"abi": "x86_64",
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"status": "supported",
"default_allowed": true,
@@ -66,6 +70,19 @@
{
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "arm64-v8a",
"elf_build_id": "ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
"status": "supported",
"default_allowed": true,
"allow_legacy_binary_patches": false,
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"reason": "AArch64 arm64-v8a payload of the same 2.736.1408 release as the verified x86-64 profile; allocator, constructor replay, and pre-JNI bootstrap RVAs were derived by cross-architecture semantic matching."
},
{
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "x86_64",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"status": "supported",
"default_allowed": true,
+14 -1
View File
@@ -46,15 +46,28 @@ Each slice is named after the call it times, on the thread that made it.
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
| `texture` | `etc2 staging create`: creating a staging block for ETC2 uploads, when no block has room left | `bytes` |
| `texture` | `etc2 staging destroy`: destroying empty staging blocks beyond the four kept idle | `buffers` |
| `texture` | `etc2 release wait`: a command buffer reset waiting for its uploads' decode. Only waits of 0.1 ms or more are recorded | `ticket` |
| `memory` | `vkAllocateMemory` | `bytes`, `type` (memory type index), `result` |
| `memory` | `vkFreeMemory`, `vkUnmapMemory`: both include waiting for ETC2 decodes that still read that memory | |
| `memory` | `vkMapMemory`, `vkCreateImage`, `vkDestroyImage` | |
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | |
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | `cpu_us`: CPU time the main thread used during the call. Far below the slice's duration means the step spent it waiting |
The `frame interval (ms)` counter is the time between successive adapter
presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's
own cost per frame.
Two more counters cover the same interval. `present thread cpu (ms)` is the
CPU time the thread that presents used in it, and is absent for a frame
presented from a different thread than the one before. `process cpu (ms)` is
the CPU time of every thread together, so dividing it by the frame interval
gives the number of cores busy. A long frame with little present-thread CPU
was spent waiting.
## The benchmark runs
Run every scenario before and after the change, on the same build type
+26 -35
View File
@@ -16,7 +16,7 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
- Input tab: the `input.raw_mouse` toggle and controller options.
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
- Discord tab: every field in the `discord_rpc` block, so a friend can see
what you are playing and you can switch it all off in one click.
what you are playing and you can switch it all off in one click.
- Changes apply on next launch. A restart button restarts Nightcap.
Done when every option in `config/mocktail.example.yaml` that a player would
@@ -29,62 +29,53 @@ already hot-plug and release their held inputs when unplugged. One rough edge
is left.
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
and product IDs, are available and unused.
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
and product IDs, are available and unused.
Done when a plugged-in pad shows its own buttons.
## 3. First-load stutter
The `--profile` trace now shows shader compiles and pipeline creation, so
the stutter on first entering an experience can be measured.
- Cut the stutter the trace shows, and prove it with a before and after
trace per [BENCHMARKING.md](BENCHMARKING.md).
Done when entering a fresh experience no longer drops frames on shader
compiles.
## Shipped
- Lower input latency: frames are presented as soon as they are ready.
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
releases its held buttons and sticks instead of leaving them stuck.
releases its held buttons and sticks instead of leaving them stuck.
- Mouse look keeps responding after a long turn, instead of going dead until
the pointer travels back from past the screen edge.
the pointer travels back from past the screen edge.
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
deltas in host units, so fractional display scaling no longer changes
first-person sensitivity.
deltas in host units, so fractional display scaling no longer changes
first-person sensitivity.
- Small textures upscaled 4x by default, with PNG overrides in
`~/.config/mocktail/textures`.
`~/.config/mocktail/textures`.
- ETC2 textures decoded on desktop GPUs without stutter.
- No more error 319 kicks after joining a server.
- Roblox sees the real host RAM and screen size.
- Desktop launcher that works on Wayland and finds the installed binary.
- Discord Rich Presence on by default, with the experience name, creator,
elapsed time, the experience icon with a Roblox badge, and a join button
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
elapsed time, the experience icon with a Roblox badge, and a join button
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
- Custom Discord presence: title, details, state, and large and small icons
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
`{creator_name}` placeholders.
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
`{creator_name}` placeholders.
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
including shader and pipeline creation, readable in `ui.perfetto.dev`.
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
`scripts/summarize_profile_trace.py` builds the before and after table.
including shader and pipeline creation, readable in `ui.perfetto.dev`.
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
`scripts/summarize_profile_trace.py` builds the before and after table.
- Texture uploads no longer stall the frame they land on: ETC2 decode and
resampling run on a persistent worker pool.
resampling run on a persistent worker pool.
- Less stutter while joining an experience and while textures load in. See
the [changelog](../CHANGELOG.md) for the numbers.
- The host main thread rests between engine polls instead of spinning, and
GameMode's core pinning is released, so Roblox's workers spread across
every core. See [PERFORMANCE.md](PERFORMANCE.md).
GameMode's core pinning is released, so Roblox's workers spread across
every core. See [PERFORMANCE.md](PERFORMANCE.md).
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
upstream.
upstream.
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
repo on GitHub Pages.
repo on GitHub Pages.
## Not planned
- Replacing the `mocktail` binary and config names. Keeping them makes
upstream merges clean.
- DEB, RPM, and AUR packages. Upstream ships those for unmodified Mocktail.
upstream merges clean.
+30
View File
@@ -33,6 +33,23 @@ void SetLegacyBionicDiagnosticsEnabled(bool enabled);
// guest layout explicit instead of exposing the incompatible host libc type.
using MocktailBionicMutexAttr = int64_t;
// Bionic LP64 pthread_attr_t: 56 bytes with explicit field offsets. The host
// glibc layout is opaque and wider on aarch64, so guest buffers must be read
// and written through this struct only.
struct MocktailBionicPthreadAttr {
std::uint32_t flags;
void* stack_base;
std::size_t stack_size;
std::size_t guard_size;
std::int32_t sched_policy;
std::int32_t sched_priority;
char reserved[16];
};
static_assert(sizeof(MocktailBionicPthreadAttr) == 56);
inline constexpr std::uint32_t kMocktailBionicAttrFlagDetached = 0x1;
inline constexpr std::uint32_t kMocktailBionicAttrFlagUserStack = 0x2;
extern "C" {
void mocktail_set_current_jni_env(void* env);
@@ -84,6 +101,19 @@ int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
size_t stack_size);
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
int detach_state);
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
const struct sched_param* parameters);
int mocktail_pthread_getattr_np(pthread_t thread,
MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
void** stack_base, size_t* stack_size);
extern void* mocktail_gameactivity_on_start_native;
extern void* mocktail_gameactivity_on_resume_native;
extern void* mocktail_gameactivity_on_surface_created_native;
@@ -25,11 +25,12 @@ void ConfigureBionicPthreadThreadInitializer(
} // namespace mocktail::compat
struct MocktailBionicPthreadAttr;
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void* (*start_routine)(void*),
void* argument);
extern "C" int mocktail_bionic_pthread_create(
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument);
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
extern "C" int mocktail_bionic_pthread_setschedparam(
+38
View File
@@ -0,0 +1,38 @@
#ifndef MOCKTAIL_COMPAT_GUEST_ABI_H_
#define MOCKTAIL_COMPAT_GUEST_ABI_H_
#include <elf.h>
#include <string_view>
namespace mocktail::compat {
#if defined(__aarch64__)
// Android ABI name as it appears in APK lib/ directories and build metadata.
inline constexpr std::string_view kGuestAbi = "arm64-v8a";
// APK split-config name carrying the guest native libraries.
inline constexpr std::string_view kGuestApkSplitName = "config.arm64_v8a";
// File name the payload store uses for the split APK.
inline constexpr std::string_view kGuestSplitApkFile =
"split_config.arm64_v8a.apk";
// sha256 map key holding the split APK digest in payload metadata.
inline constexpr std::string_view kGuestSplitApkHashKey = "arm64_v8a_split_apk";
// Short CPU label for device metadata and user-facing strings.
inline constexpr std::string_view kGuestCpuName = "aarch64";
inline constexpr int kGuestElfMachine = EM_AARCH64;
#elif defined(__x86_64__)
inline constexpr std::string_view kGuestAbi = "x86_64";
inline constexpr std::string_view kGuestApkSplitName = "config.x86_64";
inline constexpr std::string_view kGuestSplitApkFile = "split_config.x86_64.apk";
inline constexpr std::string_view kGuestSplitApkHashKey = "x86_64_split_apk";
inline constexpr std::string_view kGuestCpuName = "x86_64";
inline constexpr int kGuestElfMachine = EM_X86_64;
#else
#error "mocktail only supports aarch64 and x86_64 hosts"
#endif
// APK zip entry holding the guest libroblox.so.
inline constexpr std::string_view kGuestLibApkEntryPrefix = "lib/";
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_GUEST_ABI_H_
+190
View File
@@ -0,0 +1,190 @@
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <optional>
#include <string_view>
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
// stack slot, then reads the protocol from the returned holder:
//
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
// ldr x8, [xN, #0x18]
//
// These functions find that call site in the initializer's first bytes and
// check the getter's own code. Only relocated or build-varying fields (stack
// slots, frame sizes, PC-relative offsets, the call target) are masked.
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
// 2.738.1397 and 2.740.931.
namespace mocktail::compat {
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
namespace web_view_protocol_contract_detail {
inline bool MatchesMasked(std::string_view code, std::size_t offset,
std::string_view pattern,
std::string_view wildcard) {
if (offset > code.size() || code.size() - offset < pattern.size()) {
return false;
}
for (std::size_t index = 0; index < pattern.size(); ++index) {
if (wildcard[index] == 'x') continue;
if (code[offset + index] != pattern[index]) return false;
}
return true;
}
// AArch64 instructions are little-endian words.
inline std::uint32_t Word(std::string_view code, std::size_t index) {
std::uint32_t word = 0;
for (std::size_t byte = 0; byte < 4; ++byte) {
word |= static_cast<std::uint32_t>(
static_cast<unsigned char>(code[index * 4 + byte]))
<< (8 * byte);
}
return word;
}
// One AArch64 instruction word: bits outside `mask` may vary.
struct MaskedWord {
std::uint32_t mask;
std::uint32_t value;
};
inline constexpr std::uint32_t kExact = 0xffffffffU;
// adrp Xd, page: immlo and immhi vary.
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
// add Xd, Xn, #imm12: imm12 varies.
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
} // namespace web_view_protocol_contract_detail
// Returns the getter address called from the x86-64 initializer at
// `address`, or nullopt unless the frame setup and exactly one call site
// match.
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
std::string_view code, std::uintptr_t address) {
using web_view_protocol_contract_detail::MatchesMasked;
// push %rbp; mov %rsp,%rbp
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
return std::nullopt;
}
static constexpr std::string_view kCallSite(
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
"\x18",
19);
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
static constexpr std::size_t kCallDisplacement = 8;
const std::string_view window =
code.substr(0, kWebViewProtocolInitializerScanBytes);
std::optional<std::size_t> call_site;
for (std::size_t offset = 0; offset < window.size(); ++offset) {
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
if (call_site.has_value()) return std::nullopt;
call_site = offset;
}
}
if (!call_site.has_value()) return std::nullopt;
std::int32_t displacement = 0;
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
sizeof(displacement));
const std::uintptr_t next_instruction =
address + *call_site + kCallDisplacement + sizeof(displacement);
return next_instruction +
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
}
// The x86-64 getter stores the holder returned by the subsystem's acquire
// into its result slot (%rdi) and returns that slot.
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
return web_view_protocol_contract_detail::MatchesMasked(
code, 0,
std::string_view(
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
kWebViewProtocolGetterBytesX86_64),
"...........xxxx.......xxxx..................");
}
// Returns the getter address called from the AArch64 initializer at
// `address`, or nullopt unless exactly one call site matches.
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
std::string_view code, std::uintptr_t address) {
using web_view_protocol_contract_detail::Word;
const std::size_t words =
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
std::optional<std::uintptr_t> getter;
for (std::size_t index = 0; index + 4 <= words; ++index) {
const std::uint32_t slot_address = Word(code, index);
const std::uint32_t call = Word(code, index + 1);
const std::uint32_t handle_load = Word(code, index + 2);
const std::uint32_t protocol_load = Word(code, index + 3);
// add x8, sp, #slot
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
// bl getter
if ((call & 0xfc000000U) != 0x94000000U) continue;
// ldr xN, [sp, #slot]
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
// ldr x8, [xN, #0x18]
const std::uint32_t handle_register = handle_load & 0x1fU;
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
if (getter.has_value()) return std::nullopt;
// imm26 is a signed word offset from the bl instruction.
const std::int64_t imm26 = call & 0x03ffffffU;
const std::int64_t words_from_call =
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
getter = address + (index + 1) * 4 +
static_cast<std::uintptr_t>(words_from_call * 4);
}
return getter;
}
// The AArch64 getter saves its result slot (x8) and stores the holder
// returned by the subsystem's acquire there.
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
using namespace web_view_protocol_contract_detail;
static constexpr MaskedWord kGetter[] = {
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
{kExact, 0x910003fdU}, // mov x29, sp
{kAdrpMask, 0x90000009U}, // adrp x9, guard
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
{kExact, 0xaa0803f3U}, // mov x19, x8
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
{kExact, 0x36000109U}, // tbz w9, #0, initialize
{kAdrpMask, 0x90000000U}, // adrp x0, holder
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
{kExact, 0x94000017U}, // bl acquire
{kExact, 0xf9000260U}, // str x0, [x19]
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
{kExact, 0xd65f03c0U}, // ret
};
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
kWebViewProtocolGetterBytesArm64);
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
++index) {
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
return false;
}
}
return true;
}
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
+6
View File
@@ -48,9 +48,15 @@ struct PlatformIdentity {
std::string manufacturer = "Mocktail";
std::string model = "Mocktail Headless";
std::string brand = "Mocktail";
#if defined(__aarch64__)
std::string device_code = "linux-arm64";
std::string device_sku = "mocktail-arm64";
std::string soc_model = "aarch64";
#else
std::string device_code = "linux-x86_64";
std::string device_sku = "mocktail-x86_64";
std::string soc_model = "x86_64";
#endif
};
struct RobloxCredentialView {
@@ -6,6 +6,9 @@
#if defined(__x86_64__)
#include <xmmintrin.h>
#endif
#if defined(__aarch64__)
#include <cstdint>
#endif
namespace mocktail::audio {
@@ -22,16 +25,23 @@ class FmodThreadFloatingPointMode final {
delete;
bool Enable(const char* thread_name) noexcept {
#if defined(__x86_64__)
#if defined(__x86_64__) || defined(__aarch64__)
if (active_ || thread_name == nullptr) return false;
const std::string_view name(thread_name);
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
return false;
}
#if defined(__x86_64__)
const unsigned int control = _mm_getcsr();
original_mode_ = control & kModeMask;
_mm_setcsr(control | kModeMask);
#else
std::uint64_t control;
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
original_mode_ = control & kModeMask;
__asm__ volatile("msr fpcr, %0" ::"r"(control | kModeMask));
#endif
active_ = true;
return true;
#else
@@ -41,11 +51,18 @@ class FmodThreadFloatingPointMode final {
}
void Restore() noexcept {
#if defined(__x86_64__)
#if defined(__x86_64__) || defined(__aarch64__)
if (active_) {
// Restore only our two bits, preserving rounding, exception masks and
// status flags set by the guest while the thread was attached.
#if defined(__x86_64__)
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
#else
std::uint64_t control;
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
__asm__ volatile("msr fpcr, %0" ::"r"((control & ~kModeMask) |
original_mode_));
#endif
active_ = false;
}
#endif
@@ -56,6 +73,13 @@ class FmodThreadFloatingPointMode final {
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
unsigned int original_mode_ = 0;
bool active_ = false;
#elif defined(__aarch64__)
// FPCR FZ flushes single/double subnormals; FZ16 covers the half-precision
// path used by some voice DSP filters.
static constexpr std::uint64_t kModeMask =
(std::uint64_t{1} << 24) | (std::uint64_t{1} << 19);
std::uint64_t original_mode_ = 0;
bool active_ = false;
#endif
};
@@ -24,6 +24,35 @@ struct TraceArg {
// CLOCK_MONOTONIC in nanoseconds.
std::uint64_t TraceClockNanos();
// CPU time used so far by the calling thread, in nanoseconds.
std::uint64_t TraceThreadCpuNanos();
// CPU time used so far by every thread of the process, in nanoseconds.
std::uint64_t TraceProcessCpuNanos();
// CPU time spent between two successive presents.
struct FrameCpuSample {
double process_ms = 0;
// Set only when the previous present came from the same thread; thread CPU
// clocks of different threads cannot be subtracted.
bool has_thread = false;
double thread_ms = 0;
};
// Turns CPU clocks read at each present into per-frame CPU time.
// Thread-safe.
class FrameCpuSampler final {
public:
// Returns false for the first present, which ends no frame.
bool Sample(std::uint64_t thread_id, std::uint64_t thread_cpu_ns,
std::uint64_t process_cpu_ns, FrameCpuSample* sample);
private:
std::mutex mutex_;
bool has_previous_ = false;
std::uint64_t previous_thread_id_ = 0;
std::uint64_t previous_thread_cpu_ns_ = 0;
std::uint64_t previous_process_cpu_ns_ = 0;
};
// Writes the Trace Event Format JSON array read by chrome://tracing and
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
@@ -47,6 +47,10 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
std::uint32_t SmallTextureUpscale(const char* value);
// Staging for decoded uploads is carved out of host-visible blocks this
// large; an upload that needs more gets a block of its own.
inline constexpr VkDeviceSize kEtc2StagingBlockBytes = 16 * 1024 * 1024;
// A timeline semaphore and the value an asynchronous decode will signal.
// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
// calling thread and the submit needs no extra wait.
@@ -61,11 +65,12 @@ struct Etc2SubmitWait {
// that is filled by decoding the application's staging bytes when the
// command buffer is submitted. Every submit decodes again, as a native copy
// re-reads its source on each execution.
// Staging buffers live until their command buffer is begun, reset or freed.
// Staging ranges live until their command buffer is begun, reset or freed.
// MOCKTAIL_DISABLE_ETC2_EMULATION=1 turns the emulation off.
class VulkanEtc2Emulation final {
public:
VulkanEtc2Emulation();
explicit VulkanEtc2Emulation(
VkDeviceSize staging_block_bytes = kEtc2StagingBlockBytes);
~VulkanEtc2Emulation();
VulkanEtc2Emulation(const VulkanEtc2Emulation&) = delete;
VulkanEtc2Emulation& operator=(const VulkanEtc2Emulation&) = delete;
+94
View File
@@ -0,0 +1,94 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include <string>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
// Exact JNI entrypoints behind the APK AccountProtocol device-integrity
// handlers: JNIAccountProtocol name getters and MessageBus raw handlers.
struct RobloxAccountProtocolSymbols {
using GetStringFn = jstring (*)(JNIEnv*, jclass);
GetStringFn get_protocol_name = nullptr;
GetStringFn get_device_integrity_available_method_name = nullptr;
GetStringFn get_integrity_token_method_name = nullptr;
GetStringFn get_support_key = nullptr;
GetStringFn get_token_key = nullptr;
GetStringFn get_result_key = nullptr;
void (*set_request_handler_raw)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_request_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return get_protocol_name && get_device_integrity_available_method_name &&
get_integrity_token_method_name && get_support_key &&
get_token_key && get_result_key && set_request_handler_raw &&
clear_request_handler;
}
};
struct RobloxAccountProtocolObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_request_handler)(void*, std::shared_ptr<void>,
std::string (*)(void*, JNIEnv*,
jstring)) = nullptr;
void (*clear_request_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_request_handler &&
clear_request_handler;
}
};
// Answers AccountProtocol device-integrity requests for a host that cannot
// produce Play Integrity tokens. No integrity token is ever produced.
//
// DeviceIntegrityAvailable reports no support. The APK handler always reports
// support, so this answer differs from Android: it tells Roblox's challenge
// flow up front that no token can come, instead of letting it request one.
//
// GetIntegrityToken returns at once with an empty token and
// TOKEN_PROVIDER_UNINITIALIZED, the APK's answer when its Play token provider
// never initialized. The APK answers INVALID_NONCE for a request without a
// requestHash; this bridge never inspects the request, so it cannot.
// LuaApp bounds each token request with its
// DeviceIntegrityNativeTimeoutMilliseconds flag; an immediate answer keeps a
// login challenge from sitting out that bound.
class RobloxAccountBridge final {
public:
RobloxAccountBridge(JniEnvironmentProvider environment,
RobloxAccountProtocolSymbols symbols,
RobloxAccountProtocolObjects objects);
~RobloxAccountBridge();
RobloxAccountBridge(const RobloxAccountBridge&) = delete;
RobloxAccountBridge& operator=(const RobloxAccountBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxAccountProtocolSymbols symbols_;
const RobloxAccountProtocolObjects objects_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
@@ -3,6 +3,7 @@
#include <jni.h>
#include <chrono>
#include <cstdint>
#include <deque>
#include <memory>
@@ -17,6 +18,7 @@
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_experience_presence.h"
#include "runtime/roblox_fresh_game_launch_controller.h"
#include "runtime/roblox_account_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
#include "runtime/webview_roblox_cookie.h"
@@ -120,7 +122,8 @@ class RobloxExperienceComposition final {
RobloxExperienceSurfaceProvider surface_provider = {},
RobloxExperiencePresenceObserver presence_observer = {},
bool clear_persisted_web_view_cookie = false,
bool microphone_enabled = false);
bool microphone_enabled = false,
RobloxAccountProtocolSymbols account_symbols = {});
~RobloxExperienceComposition();
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
@@ -162,6 +165,7 @@ class RobloxExperienceComposition final {
bool visible = true;
bool back_navigation_disabled = false;
bool show_domain_as_title = false;
bool close_when_challenge_solved = false;
};
static Status DispatchLaunch(void* context,
@@ -201,8 +205,11 @@ class RobloxExperienceComposition final {
const WebSurfacePresentation& presentation);
// WebViewProtocol and BrowserService are two logical transports over the
// APK's single physical WebView container. Either protocol may close or
// mutate the surface that the other one opened.
Status CloseWebSurface();
// mutate the surface that the other one opened. A nonzero
// expected_logical_generation closes only that surface.
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
// Closes a solved challenge that Roblox left open past its grace period.
Status CloseUnclaimedSolvedChallenge();
void HandleWebSurfaceExit(uint64_t process_generation);
Status Dispatch(const RobloxExperienceLaunchRequest& request);
Status PromoteAuthenticatedSession();
@@ -229,6 +236,7 @@ class RobloxExperienceComposition final {
const RobloxWebViewMessageBusSymbols web_view_symbols_;
const RobloxBrowserServiceSymbols browser_service_symbols_;
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
const RobloxAccountProtocolSymbols account_symbols_;
const bool microphone_enabled_;
const RobloxGameSessionSymbols game_symbols_;
const RobloxExperienceJniFactory jni_factory_;
@@ -248,6 +256,8 @@ class RobloxExperienceComposition final {
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
// Optional: a payload without the AccountProtocol entrypoints still starts.
std::unique_ptr<RobloxAccountBridge> account_bridge_;
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
std::shared_ptr<LifecycleTarget> lifecycle_target_;
@@ -257,6 +267,13 @@ class RobloxExperienceComposition final {
uint64_t web_surface_logical_generation_ = 0;
uint64_t next_web_surface_process_generation_ = 1;
uint64_t next_web_surface_logical_generation_ = 1;
// The current surface closes as soon as its solved challenge is forwarded.
bool web_surface_close_when_challenge_solved_ = false;
// Set when a solved challenge is forwarded; applies only to that logical
// surface generation.
std::optional<std::chrono::steady_clock::time_point>
solved_challenge_close_deadline_;
uint64_t solved_challenge_logical_generation_ = 0;
SecureWebViewRobloxCookie web_view_cookie_;
std::string web_view_cookie_initialization_error_;
bool web_view_cookie_synchronized_ = false;
+4 -3
View File
@@ -40,9 +40,10 @@ struct AndroidKeyMapping {
// through with an ABI-incompatible numeric value.
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
// numbers are not numerically compatible with Android button bit values.
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
// Translates SDL button numbers to the nativePassMouseButton argument.
// The first three are Roblox's zero-based MouseButton1/2/3 indices, not Android
// MotionEvent button bits.
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button);
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
int32_t RobloxGamepadType(platform::GamepadFamily family);
+7
View File
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
Status ParseRobloxLaunchUri(std::string_view uri,
RobloxExperienceLaunchRequest* request);
// Converts a WebView launchGame command
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
// contract. Returns kUnsupported for other commands and kInvalidArgument for
// a malformed launchGame request.
Status ParseRobloxWebViewLaunchGame(std::string_view command,
RobloxExperienceLaunchRequest* request);
} // namespace runtime
} // namespace mocktail
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#include "runtime/roblox_account_bridge.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
@@ -15,6 +16,8 @@ struct RobloxPlatformWebSymbols {
RobloxWebViewMessageBusSymbols web_view;
RobloxBrowserServiceSymbols browser_service;
RobloxPermissionsMessageBusSymbols permissions;
// Optional and excluded from complete(); see RobloxExperienceComposition.
RobloxAccountProtocolSymbols account;
bool complete() const {
return web_view.complete() && browser_service.complete() &&
+42
View File
@@ -13,6 +13,8 @@
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <type_traits>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
@@ -42,6 +44,10 @@ struct RobloxWebViewOpenRequest {
std::optional<bool> show_domain_as_title;
std::optional<bool> back_button_visible;
std::optional<bool> hide_header;
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
// window once its solved captcha has been forwarded, as Android's captcha
// activity finishes itself on success.
bool close_when_challenge_solved = false;
};
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
@@ -61,6 +67,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
RobloxWebViewMutationRequest* request);
Status ParseRobloxCaptchaOpenJson(const std::string& json,
RobloxWebViewOpenRequest* request);
// True for a page's Navigation.navigateToFeature command reporting a solved
// verification (see webview::IsRobloxChallengeSolvedFeature).
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
Status ParseRobloxProfileViewUrl(const std::string& url,
RobloxWebViewOpenRequest* request);
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
@@ -108,6 +117,33 @@ using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
using UpdateRobloxCookieSetHandlerFn =
void (*)(JNIEnv*, jobject, jobject);
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
// and its handleJavascriptCallback subscriber, exist only while a handle is
// held. Roblox's handle type is not trivially destructible, so its getter
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
// AArch64). The user-provided destructor gives this type the same calling
// convention. It does not release the handle.
class RobloxWebViewProtocolHandle final {
public:
RobloxWebViewProtocolHandle() = default;
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
default;
~RobloxWebViewProtocolHandle() {}
void* holder() const { return holder_; }
private:
void* holder_ = nullptr;
};
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
// resolver finds it through the first call in
// WebViewProtocol.initializeAndroidWebViewProtocol.
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
struct RobloxWebViewMessageBusSymbols {
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
@@ -129,6 +165,8 @@ struct RobloxWebViewMessageBusSymbols {
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
// Optional: found by code contract, not by symbol name.
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
bool complete() const {
return get_open_window_id != nullptr &&
@@ -287,6 +325,10 @@ private:
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
raw_callback_targets_{};
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
// The reference this counts is kept for the process lifetime, as Roblox's
// Android app start keeps its own. Neither Shutdown nor destruction
// releases it.
RobloxWebViewProtocolHandle web_view_protocol_handle_;
std::size_t in_flight_dispatches_ = 0;
bool initializing_ = false;
bool shutting_down_ = false;
+1 -1
View File
@@ -14,7 +14,7 @@ struct ActivePayloadPaths {
std::filesystem::path root;
std::filesystem::path roblox_library;
std::filesystem::path base_apk;
std::filesystem::path x86_64_split_apk;
std::filesystem::path arch_split_apk;
std::filesystem::path assets_content;
std::filesystem::path compatibility_manifest;
std::filesystem::path host_abi_profile;
@@ -0,0 +1,19 @@
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
#include <string_view>
namespace mocktail::webview {
// Navigation.navigateToFeature features a Roblox verification page sends when
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
// helper and the runtime both classify page events with this list.
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
feature == "ChallengeCompleted";
}
} // namespace mocktail::webview
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
@@ -0,0 +1,10 @@
# Allowed reduced aarch64 packages from pkgforge-dev/archlinux-pkgs-debloated.
# The installer verifies the rolling release asset IDs and SHA256 digests.
ffmpeg-mini-aarch64.pkg.tar.zst
gdk-pixbuf2-mini-aarch64.pkg.tar.xz
gtk4-mini-aarch64.pkg.tar.zst
icu-mini-aarch64.pkg.tar.zst
librsvg-mini-aarch64.pkg.tar.zst
libxml2-mini-aarch64.pkg.tar.zst
mesa-mini-aarch64.pkg.tar.zst
opus-mini-aarch64.pkg.tar.zst
@@ -57,6 +57,18 @@
<binary>mocktail</binary>
</provides>
<releases>
<release version="0.3.2" date="2026-09-29">
<description>
<ul>
<li>Added experimental support for ARM64 Linux.</li>
<li>Fixed login stalling after passing a verification check.</li>
<li>Fixed login check windows staying open after they were solved.</li>
<li>Fixed Play on a private server doing nothing until a sidebar tab was clicked.</li>
<li>Less stutter while joining a game and while textures load in.</li>
<li>Middle click and natural scrolling now work as expected.</li>
</ul>
</description>
</release>
<release version="0.3.1" date="2026-09-20">
<description>
<ul>
+2
View File
@@ -461,6 +461,7 @@ LatestSupportedBootstrapProfile() {
.version_code <= ($remote_version_code | tonumber)) and
.status == "supported" and .default_allowed == true and
.allow_legacy_binary_patches == false and
(.abi // "x86_64") == "x86_64" and
(.version_name | type) == "string" and
(.version_name | length) > 0)] |
group_by(.version_code) |
@@ -495,6 +496,7 @@ SelectSupportedBootstrapFallback() {
bootstrap_source="$(jq -er --argjson version_code "${fallback_version_code}" '
[.sources[]? |
select(.version_code == $version_code and
(.abi // "x86_64") == "x86_64" and
.provider == "uptodown")] |
if length == 1 then .[0].provider else empty end
' "${BOOTSTRAP_SOURCES_PATH}" 2>/dev/null || true)"
+2 -1
View File
@@ -329,6 +329,7 @@ def run_trial(args, max_ctors, skipped_offsets, trial_id):
log_path = args.log_dir / f"trial_{trial_id:03d}_max_{max_ctors}.log"
env = os.environ.copy()
env.update({
"ROBLOX_LIB_PATH": str(args.libroblox),
"MOCKTAIL_RUN_LIBROBLOX_CTORS": "1",
"MOCKTAIL_LIBROBLOX_CTOR_POLICY": args.ctor_policy,
"MOCKTAIL_GRAPHICS_BACKEND": args.graphics_backend,
@@ -500,7 +501,7 @@ def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--project-root", type=Path, default=project_root)
parser.add_argument("--run-script", type=Path,
default=project_root / "scripts" / "run_sober.sh")
default=project_root / "build" / "mocktail")
parser.add_argument("--libroblox", type=Path,
default=project_root / "rbx_bin" / "libroblox.so")
parser.add_argument("--log-dir", type=Path,
+9 -6
View File
@@ -10,18 +10,18 @@ readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
BUILD_DIR="${PROJECT_ROOT}/build-flatpak"
MANIFEST="${PROJECT_ROOT}/packaging/flatpak/io.github.CoderDayton.nightcap.json"
JOBS="${MOCKTAIL_FLATPAK_JOBS:-4}"
JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"
Usage() {
cat <<'EOF'
Usage: scripts/build_flatpak.sh [OPTIONS]
Build and install Mocktail as a per-user x86-64 Flatpak.
Build and install Mocktail as a per-user Flatpak for the host architecture.
Options:
--build-dir DIR Builder output directory (default: build-flatpak).
--manifest FILE Flatpak manifest path.
--jobs N Maximum parallel build jobs (default: 4).
--jobs N Maximum parallel build jobs (default: 2).
-h, --help Show this help.
EOF
}
@@ -93,8 +93,11 @@ done
[[ "${JOBS}" =~ ^[1-9][0-9]*$ && "${JOBS}" -le 256 ]] ||
Die "--jobs must be an integer between 1 and 256"
[[ "$(uname -m)" == x86_64 ]] ||
Die "the Roblox payload and Flatpak manifest currently require x86-64"
HOST_ARCH="$(uname -m)"
case "${HOST_ARCH}" in
x86_64|aarch64) ;;
*) Die "Flatpak builds require an x86_64 or aarch64 Linux host" ;;
esac
command -v flatpak >/dev/null 2>&1 || Die "flatpak is required"
MANIFEST="$(realpath -e -- "${MANIFEST}")" ||
@@ -123,7 +126,7 @@ CleanupStaleBuilderMounts
cd -- "${PROJECT_ROOT}"
exec "${builder[@]}" \
--arch=x86_64 \
--arch="${HOST_ARCH}" \
--jobs="${JOBS}" \
--force-clean \
--install-deps-from=flathub \
+1
View File
@@ -61,6 +61,7 @@ def supported_profiles(catalog: dict) -> list[dict]:
if isinstance(profile, dict)
and profile.get("status") == "supported"
and profile.get("default_allowed") is True
and profile.get("abi", ARCHITECTURE) == ARCHITECTURE
and isinstance(profile.get("version_name"), str)
and isinstance(profile.get("version_code"), int)
]
+12 -3
View File
@@ -18,12 +18,15 @@ AnyLinuxDependencyRequireContainer() {
}
AnyLinuxDependencySnapshot() {
local manifest="$1" release="$2" filename remainder names_json
local manifest="$1" release="$2" host_arch="${3:-$(uname -m)}"
local filename remainder names_json
[[ "${host_arch}" == x86_64 || "${host_arch}" == aarch64 ]] ||
AnyLinuxDependencyDie 'unsupported AppImage architecture'
local -a names=()
local -A seen=()
while read -r filename remainder || [[ -n "${filename}" ]]; do
[[ -n "${filename}" && "${filename}" != \#* ]] || continue
[[ "${filename}" =~ ^[a-z0-9-]+-mini-x86_64\.pkg\.tar\.zst$ &&
[[ "${filename}" =~ ^[a-z0-9-]+-mini-${host_arch}\.pkg\.tar\.(zst|xz)$ &&
-z "${remainder}" && -z "${seen[${filename}]:-}" ]] ||
AnyLinuxDependencyDie 'invalid or duplicate dependency manifest entry'
seen["${filename}"]=1
@@ -108,7 +111,13 @@ AnyLinuxDependenciesMain() (
AnyLinuxDependencyRequireContainer
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.txt"
local host_arch="$(uname -m)" manifest_name
case "${host_arch}" in
x86_64) manifest_name=anylinux-dependencies.txt ;;
aarch64) manifest_name=anylinux-dependencies-aarch64.txt ;;
*) AnyLinuxDependencyDie "unsupported AppImage architecture: ${host_arch}" ;;
esac
readonly MANIFEST="${ROOT}/packaging/${manifest_name}"
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
AnyLinuxDownloadDependencies "${MANIFEST}" "${DOWNLOADS}"
+8 -2
View File
@@ -110,8 +110,14 @@ for elf in "$@"; do
fi
machine="$(LC_ALL=C readelf --file-header "${elf}" 2>/dev/null |
sed -n 's/^[[:space:]]*Machine:[[:space:]]*//p')"
if [[ "${machine}" != *X86-64* ]]; then
printf 'ABI check: %s is not x86-64 (%s)\n' "${elf}" "${machine}" >&2
case "$(uname -m)" in
x86_64) expected_machine='X86-64' ;;
aarch64) expected_machine='AArch64' ;;
*) expected_machine='X86-64' ;;
esac
if [[ "${machine}" != *"${expected_machine}"* ]]; then
printf 'ABI check: %s is not %s (%s)\n' "${elf}" "${expected_machine}" \
"${machine}" >&2
status=1
fi
+16 -2
View File
@@ -16,6 +16,8 @@
#include <cstdlib>
#include <cstring>
#include "compat/bionic_pthread_create_runtime.h"
#include "compat/guest_abi.h"
#include "compat/http_client_spin_guard.h"
#include "window/window.h"
@@ -31,6 +33,14 @@ const char* EnvironmentOr(const char* name, const char* fallback) {
return value != nullptr && value[0] != '\0' ? value : fallback;
}
const char* GuestAbiPropertyValue() {
#if defined(__aarch64__)
return "arm64-v8a";
#else
return "x86_64";
#endif
}
int CallHostVsnprintf(char* dst, size_t count, const char* format,
va_list args) {
using HostVsnprintf = int (*)(char*, size_t, const char*, va_list);
@@ -173,8 +183,12 @@ int __system_property_get(const char* name, char* value) {
result = "35";
} else if (std::strcmp(name, "ro.build.version.release") == 0) {
result = "15";
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0) {
result = "x86_64";
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0 ||
std::strcmp(name, "ro.product.cpu.abilist") == 0 ||
std::strcmp(name, "ro.product.cpu.abilist64") == 0) {
result = GuestAbiPropertyValue();
} else if (std::strcmp(name, "ro.product.cpu.abilist32") == 0) {
result = "";
} else if (std::strcmp(name, "ro.product.manufacturer") == 0) {
result = EnvironmentOr("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
} else if (std::strcmp(name, "ro.product.model") == 0) {
+167 -71
View File
@@ -1,10 +1,14 @@
#include "compat/bionic_pthread_create_runtime.h"
#include "compat/bionic_abi_exports.h"
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cstring>
#include <limits>
#include <new>
@@ -69,24 +73,28 @@ size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
return guest_stack_size;
}
int CopySupportedThreadAttributes(const pthread_attr_t& source,
// Guest pthread_attr_t buffers use the Bionic LP64 layout; they are never a
// host pthread_attr_t, so decode the fixed fields directly.
int GuestDetachState(const MocktailBionicPthreadAttr& source) noexcept {
return (source.flags & kMocktailBionicAttrFlagDetached) != 0
? PTHREAD_CREATE_DETACHED
: PTHREAD_CREATE_JOINABLE;
}
size_t GuestStackSize(const MocktailBionicPthreadAttr& source) noexcept {
return source.stack_size != 0 ? source.stack_size
: kBionicLp64DefaultThreadStackSize;
}
int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
pthread_attr_t* destination) noexcept {
int detach_state = PTHREAD_CREATE_JOINABLE;
int result = pthread_attr_getdetachstate(&source, &detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_setdetachstate(destination, detach_state);
int result =
pthread_attr_setdetachstate(destination, GuestDetachState(source));
if (result != 0) {
return result;
}
size_t guard_size = 0;
result = pthread_attr_getguardsize(&source, &guard_size);
if (result != 0) {
return result;
}
result = pthread_attr_setguardsize(destination, guard_size);
result = pthread_attr_setguardsize(destination, source.guard_size);
if (result != 0) {
return result;
}
@@ -95,53 +103,38 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
// pthread_attr_setstack. Copying pthread_attr_getstack's address is unsafe:
// glibc represents an automatically allocated stack with a synthetic
// address derived from its size.
size_t stack_size = 0;
result = pthread_attr_getstacksize(&source, &stack_size);
if (result != 0) {
return result;
}
result = pthread_attr_setstacksize(destination,
HostStackSizeForGuest(stack_size));
result = pthread_attr_setstacksize(
destination, HostStackSizeForGuest(GuestStackSize(source)));
if (result != 0) {
return result;
}
int inherit_scheduler = PTHREAD_INHERIT_SCHED;
result = pthread_attr_getinheritsched(&source, &inherit_scheduler);
if (result != 0) {
return result;
// Bionic applies the attribute's scheduling policy at creation whenever it
// differs from the normal policy.
if (RejectRealtimeScheduling(source.sched_policy)) {
return EPERM;
}
if (inherit_scheduler == PTHREAD_EXPLICIT_SCHED) {
int scheduler_policy = 0;
sched_param scheduler_parameters{};
result = pthread_attr_getschedpolicy(&source, &scheduler_policy);
if (result == 0) {
result = pthread_attr_getschedparam(&source, &scheduler_parameters);
}
if (result == 0 && RejectRealtimeScheduling(scheduler_policy)) {
return EPERM;
}
if (result == 0) {
result = pthread_attr_setschedpolicy(destination, scheduler_policy);
}
if (result == 0) {
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
}
if (source.sched_policy != SCHED_NORMAL) {
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
if (result != 0) {
return result;
}
sched_param scheduler_parameters{};
scheduler_parameters.sched_priority = source.sched_priority;
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
if (result != 0) {
return result;
}
return pthread_attr_setinheritsched(destination, PTHREAD_EXPLICIT_SCHED);
}
return pthread_attr_setinheritsched(destination, inherit_scheduler);
return pthread_attr_setinheritsched(destination, PTHREAD_INHERIT_SCHED);
}
int CopyRequiredThreadAttributes(const pthread_attr_t& source,
int CopyRequiredThreadAttributes(const MocktailBionicPthreadAttr& source,
pthread_attr_t* destination) noexcept {
int detach_state = PTHREAD_CREATE_JOINABLE;
int result = pthread_attr_getdetachstate(&source, &detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_setdetachstate(destination, detach_state);
int result =
pthread_attr_setdetachstate(destination, GuestDetachState(source));
if (result != 0) {
return result;
}
@@ -149,32 +142,20 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source,
// The retry must retain an explicitly requested guest stack. Falling back
// to a null/default host attr is unsafe on musl, whose default stack is much
// smaller than Bionic's and can be exhausted by a single libroblox frame.
size_t stack_size = 0;
result = pthread_attr_getstacksize(&source, &stack_size);
if (result != 0) {
return result;
}
return pthread_attr_setstacksize(destination,
HostStackSizeForGuest(stack_size));
return pthread_attr_setstacksize(
destination, HostStackSizeForGuest(GuestStackSize(source)));
}
int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
int ConfigureHostSafeStackFallback(const MocktailBionicPthreadAttr* source,
pthread_attr_t* destination) noexcept {
size_t guest_stack_size = 0;
if (source != nullptr) {
int detach_state = PTHREAD_CREATE_JOINABLE;
int result = pthread_attr_getdetachstate(source, &detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_setdetachstate(destination, detach_state);
if (result != 0) {
return result;
}
result = pthread_attr_getstacksize(source, &guest_stack_size);
int result =
pthread_attr_setdetachstate(destination, GuestDetachState(*source));
if (result != 0) {
return result;
}
guest_stack_size = GuestStackSize(*source);
}
size_t host_default_stack_size = 0;
@@ -207,7 +188,8 @@ void ConfigureBionicPthreadThreadInitializer(
g_thread_initializer.store(initializer, std::memory_order_release);
}
int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
int CreateBionicPthread(pthread_t* thread,
const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument) {
if (thread == nullptr || start_routine == nullptr) {
return EINVAL;
@@ -289,10 +271,124 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
} // namespace mocktail::compat
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void* (*start_routine)(void*),
void* argument) {
extern "C" {
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr) {
if (attr == nullptr) {
return EINVAL;
}
attr->flags = 0;
attr->stack_base = nullptr;
attr->stack_size =
mocktail::compat::kBionicLp64DefaultThreadStackSize;
const long page_size = sysconf(_SC_PAGESIZE);
attr->guard_size = page_size > 0 ? static_cast<size_t>(page_size) : 0;
attr->sched_policy = SCHED_OTHER;
attr->sched_priority = 0;
std::memset(attr->reserved, 0, sizeof(attr->reserved));
return 0;
}
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr) {
(void)attr;
return 0;
}
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
size_t stack_size) {
if (attr == nullptr || stack_size < PTHREAD_STACK_MIN) {
return EINVAL;
}
attr->stack_size = stack_size;
attr->flags &= ~kMocktailBionicAttrFlagUserStack;
return 0;
}
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
int detach_state) {
if (attr == nullptr || (detach_state != PTHREAD_CREATE_DETACHED &&
detach_state != PTHREAD_CREATE_JOINABLE)) {
return EINVAL;
}
if (detach_state == PTHREAD_CREATE_DETACHED) {
attr->flags |= kMocktailBionicAttrFlagDetached;
} else {
attr->flags &= ~kMocktailBionicAttrFlagDetached;
}
return 0;
}
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
const struct sched_param* parameters) {
if (attr == nullptr || parameters == nullptr) {
return EINVAL;
}
attr->sched_priority = parameters->sched_priority;
return 0;
}
int mocktail_pthread_getattr_np(pthread_t thread,
MocktailBionicPthreadAttr* attr) {
if (attr == nullptr) {
return EINVAL;
}
pthread_attr_t host_attr;
int result = pthread_getattr_np(thread, &host_attr);
if (result != 0) {
return result;
}
void* stack_base = nullptr;
size_t stack_size = 0;
result = pthread_attr_getstack(&host_attr, &stack_base, &stack_size);
size_t guard_size = 0;
if (result == 0) {
result = pthread_attr_getguardsize(&host_attr, &guard_size);
}
int detach_state = PTHREAD_CREATE_JOINABLE;
if (result == 0) {
result = pthread_attr_getdetachstate(&host_attr, &detach_state);
}
sched_param parameters{};
int policy = SCHED_OTHER;
if (result == 0) {
result = pthread_getschedparam(thread, &policy, &parameters);
}
pthread_attr_destroy(&host_attr);
if (result != 0) {
return result;
}
attr->flags = detach_state == PTHREAD_CREATE_DETACHED
? kMocktailBionicAttrFlagDetached
: 0;
attr->stack_base = stack_base;
attr->stack_size = stack_size;
attr->guard_size = guard_size;
attr->sched_policy = policy;
attr->sched_priority = parameters.sched_priority;
std::memset(attr->reserved, 0, sizeof(attr->reserved));
return 0;
}
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
void** stack_base, size_t* stack_size) {
if (attr == nullptr) {
return EINVAL;
}
if (stack_base != nullptr) {
*stack_base = attr->stack_base;
}
if (stack_size != nullptr) {
*stack_size = attr->stack_size;
}
return 0;
}
} // extern "C"
extern "C" int mocktail_bionic_pthread_create(
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument) {
return mocktail::compat::CreateBionicPthread(thread, attr, start_routine,
argument);
}
+11
View File
@@ -39,12 +39,23 @@ bool PatchAbsoluteJump(uintptr_t address, void *target) noexcept {
return false;
}
#if defined(__aarch64__)
// ldr x16, #8 ; br x16 ; .quad target
// x16 is the intra-procedure-call scratch register, free to clobber.
unsigned char patch[] = {
0x50, 0x00, 0x00, 0x58, 0x00, 0x02, 0x1f, 0xd6,
0, 0, 0, 0, 0, 0, 0, 0,
};
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
std::memcpy(patch + 8, &target_address, sizeof(target_address));
#else
unsigned char patch[] = {
0x48, 0xb8, // movabs rax, imm64
0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xe0, // jmp rax
};
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
std::memcpy(patch + 2, &target_address, sizeof(target_address));
#endif
const uintptr_t page_mask = static_cast<uintptr_t>(page_size) - 1;
const uintptr_t page = address & ~page_mask;
+28
View File
@@ -6,6 +6,34 @@ namespace mocktail::compat {
namespace {
constexpr HostAbiProfile kProfiles[] = {
// Roblox 2.736.1408 arm64-v8a. Every RVA was derived from the AArch64
// payload of the same release as the x86-64 reference above, matched by
// allocator internals, init_array call graphs, and static state layout.
// Constructor policy mirrors x86-64 by semantic role: the pre-init
// entries 0..3 stay skipped, index 4 is mi_process_load (thread init
// checkpoint), index 5 is allocator wiring and index 6 is the lazy clock
// singleton getter, both excluded under host bridges only.
{"ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
{{{0x1d53840, HostBridgeKind::kAllocate, "small-allocate"},
{0x1d56c34, HostBridgeKind::kUsableSize, "usable-size"},
{0x1d6ef80, HostBridgeKind::kReallocate, "reallocate"},
{0x1d53568, HostBridgeKind::kAllocate, "allocate"},
{0x61d3850, HostBridgeKind::kAlignedAllocate,
"aligned-allocate-direct"},
{0x1d57328, HostBridgeKind::kFree, "free"}}},
6,
{0, 0, 0x7296c88, 0x400, 0x1d53c54, 0x1dc76a0,
0x71d6c90, 0x71d2c40, 0x400000},
{0x1d53568, 0x1d57328},
0x6733128,
3565,
{{{4, 5}, {7, 3565}}},
2,
{{{4, 3565}}},
1,
4,
{0x2132c5c, 0x71d6d30},
HostAllocatorStrategy::kNativeMimalloc},
// Roblox 2.736.1408. Derived from the verified 2908 profile and validated
// by two isolated no-recovery Vulkan canaries and real game sessions.
{"ade08266c67aee88ec9c1d00902150e1684dad3a",
+19 -10
View File
@@ -32,6 +32,7 @@
#include <openssl/evp.h>
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
namespace mocktail::compat {
namespace {
@@ -611,19 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
*error = "external host ABI profile has inconsistent constructor policy";
return nullptr;
}
if (owned->profile.init_array_count <= 5 ||
// The verified constructor layout is the same on both guest ABIs: a single
// process-load constructor at the thread-initializer checkpoint, two wiring
// entries excluded under host bridges, and one contiguous native-mimalloc
// replay from the checkpoint to the end of .init_array. Only the checkpoint
// index itself differs between payloads.
const std::size_t checkpoint =
owned->profile.native_mimalloc_thread_initializer_after_constructor;
if (owned->profile.init_array_count <= checkpoint + 3 ||
owned->profile.constructor_run_range_count != 2 ||
owned->profile.constructor_run_ranges[0].begin != 2 ||
owned->profile.constructor_run_ranges[0].end_exclusive != 3 ||
owned->profile.constructor_run_ranges[1].begin != 5 ||
owned->profile.constructor_run_ranges[0].begin != checkpoint ||
owned->profile.constructor_run_ranges[0].end_exclusive != checkpoint + 1 ||
owned->profile.constructor_run_ranges[1].begin != checkpoint + 3 ||
owned->profile.constructor_run_ranges[1].end_exclusive !=
owned->profile.init_array_count ||
owned->profile.native_mimalloc_constructor_run_range_count != 1 ||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin != 2 ||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin !=
checkpoint ||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
owned->profile.init_array_count ||
owned->profile.native_mimalloc_thread_initializer_after_constructor !=
2) {
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
*error =
"external host ABI profile changed the verified constructor policy";
return nullptr;
@@ -669,9 +677,10 @@ bool ValidateProfileAgainstElf(const std::string& payload_path,
}
GElf_Ehdr header{};
if (gelf_getehdr(elf.get(), &header) == nullptr ||
gelf_getclass(elf.get()) != ELFCLASS64 || header.e_machine != EM_X86_64 ||
header.e_type != ET_DYN) {
*error = "payload must be an x86-64 ET_DYN ELF object";
gelf_getclass(elf.get()) != ELFCLASS64 ||
header.e_machine != kGuestElfMachine || header.e_type != ET_DYN) {
*error = "payload must be a " + std::string(kGuestAbi) +
" ET_DYN ELF object";
return false;
}
+54 -1
View File
@@ -2,6 +2,7 @@
// Android Vulkan loader ABI -> host Vulkan loader + SDL3 WSI.
#include <dlfcn.h>
#include <pthread.h>
#include <time.h>
#include <vulkan/vulkan.h>
@@ -1150,16 +1151,28 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
return result;
}
// Samples the time between successive adapter present calls.
// Samples the time between successive adapter present calls, and the CPU
// time the presenting thread and the whole process used in it.
void RecordProfiledFrame(mocktail::graphics::ChromeTraceWriter* trace,
std::uint64_t present_start_ns) {
static std::atomic<std::uint64_t> previous_start_ns{0};
static mocktail::graphics::FrameCpuSampler cpu_sampler;
const std::uint64_t previous =
previous_start_ns.exchange(present_start_ns, std::memory_order_relaxed);
if (previous != 0 && present_start_ns > previous) {
trace->Counter("frame interval (ms)", present_start_ns,
static_cast<double>(present_start_ns - previous) / 1e6);
}
mocktail::graphics::FrameCpuSample cpu;
if (cpu_sampler.Sample(static_cast<std::uint64_t>(pthread_self()),
mocktail::graphics::TraceThreadCpuNanos(),
mocktail::graphics::TraceProcessCpuNanos(), &cpu)) {
trace->Counter("process cpu (ms)", present_start_ns, cpu.process_ms);
if (cpu.has_thread) {
trace->Counter("present thread cpu (ms)", present_start_ns,
cpu.thread_ms);
}
}
}
// Pipeline and shader creation is wrapped only while profiling, so a normal
@@ -1241,7 +1254,32 @@ VkResult VKAPI_CALL ProfiledCreatePipelineCache(
return result;
}
VkResult VKAPI_CALL ProfiledAllocateMemory(
VkDevice device, const VkMemoryAllocateInfo* allocate_info,
const VkAllocationCallbacks* allocator, VkDeviceMemory* memory) {
const auto host = reinterpret_cast<PFN_vkAllocateMemory>(
HostDeviceProc(device, "vkAllocateMemory"));
if (host == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkAllocateMemory", "memory");
const VkResult result = host(device, allocate_info, allocator, memory);
scope.Arg("bytes", allocate_info != nullptr
? static_cast<std::int64_t>(
allocate_info->allocationSize)
: 0);
scope.Arg("type", allocate_info != nullptr
? static_cast<std::int64_t>(
allocate_info->memoryTypeIndex)
: -1);
scope.Arg("result", result);
return result;
}
// Null unless profiling is on and `name` is a profiled device command.
// These take precedence over the ETC2 wrappers, so a command both wrap is
// timed inside its adapter function instead.
PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
if (name == nullptr || mocktail::graphics::ActiveProfileTrace() == nullptr) {
return nullptr;
@@ -1260,6 +1298,9 @@ PFN_vkVoidFunction ProfileAdapterProc(const char* name) {
if (std::strcmp(name, "vkCreatePipelineCache") == 0) {
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledCreatePipelineCache);
}
if (std::strcmp(name, "vkAllocateMemory") == 0) {
return reinterpret_cast<PFN_vkVoidFunction>(ProfiledAllocateMemory);
}
return nullptr;
}
@@ -2652,11 +2693,15 @@ VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceImageFormatProperties2(
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(
VkDevice device, const VkImageCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImage* image) {
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkCreateImage", "memory");
return State().etc2.CreateImage(device, create_info, allocator, image);
}
VKAPI_ATTR void VKAPI_CALL vkDestroyImage(
VkDevice device, VkImage image, const VkAllocationCallbacks* allocator) {
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkDestroyImage", "memory");
State().etc2.DestroyImage(device, image, allocator);
}
@@ -2685,6 +2730,8 @@ VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory(VkDevice device,
VkDeviceSize size,
VkMemoryMapFlags flags,
void** data) {
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkMapMemory", "memory");
return State().etc2.MapMemory(device, memory, offset, size, flags, data);
}
@@ -2696,6 +2743,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory2(VkDevice device,
VKAPI_ATTR void VKAPI_CALL vkUnmapMemory(VkDevice device,
VkDeviceMemory memory) {
// Includes waiting for ETC2 decodes that still read this memory.
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkUnmapMemory", "memory");
State().etc2.UnmapMemory(device, memory);
}
@@ -2707,6 +2757,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkUnmapMemory2(VkDevice device,
VKAPI_ATTR void VKAPI_CALL vkFreeMemory(
VkDevice device, VkDeviceMemory memory,
const VkAllocationCallbacks* allocator) {
// Includes waiting for ETC2 decodes that still read this memory.
mocktail::graphics::TraceScope scope(mocktail::graphics::ActiveProfileTrace(),
"vkFreeMemory", "memory");
State().etc2.FreeMemory(device, memory, allocator);
}
+48
View File
@@ -4,6 +4,7 @@
#include <time.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <charconv>
#include <chrono>
@@ -106,6 +107,53 @@ std::uint64_t TraceClockNanos() {
static_cast<std::uint64_t>(now.tv_nsec);
}
namespace {
std::uint64_t ClockNanos(clockid_t clock) {
timespec now{};
clock_gettime(clock, &now);
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
static_cast<std::uint64_t>(now.tv_nsec);
}
} // namespace
std::uint64_t TraceThreadCpuNanos() {
return ClockNanos(CLOCK_THREAD_CPUTIME_ID);
}
std::uint64_t TraceProcessCpuNanos() {
return ClockNanos(CLOCK_PROCESS_CPUTIME_ID);
}
bool FrameCpuSampler::Sample(std::uint64_t thread_id,
std::uint64_t thread_cpu_ns,
std::uint64_t process_cpu_ns,
FrameCpuSample* sample) {
std::lock_guard<std::mutex> lock(mutex_);
const bool had_previous = has_previous_;
// Clocks are read before the lock, so a reading can arrive after a later
// one; a reused thread id restarts its thread clock. Neither may wrap.
if (had_previous) {
*sample = {};
if (process_cpu_ns > previous_process_cpu_ns_) {
sample->process_ms =
static_cast<double>(process_cpu_ns - previous_process_cpu_ns_) / 1e6;
}
if (thread_id == previous_thread_id_ &&
thread_cpu_ns >= previous_thread_cpu_ns_) {
sample->has_thread = true;
sample->thread_ms =
static_cast<double>(thread_cpu_ns - previous_thread_cpu_ns_) / 1e6;
}
}
has_previous_ = true;
previous_thread_id_ = thread_id;
previous_thread_cpu_ns_ = thread_cpu_ns;
previous_process_cpu_ns_ = std::max(previous_process_cpu_ns_, process_cpu_ns);
return had_previous;
}
std::unique_ptr<ChromeTraceWriter> ChromeTraceWriter::Open(
const std::filesystem::path& path, std::string* error) {
std::FILE* file = std::fopen(path.c_str(), "wb");
+130 -71
View File
@@ -211,19 +211,34 @@ bool IsColorFormat(EtcFormat format) {
}
// A host-visible transfer buffer that stays mapped for its whole life.
struct Staging {
// Uploads take ranges of it front to back, and once every range has been
// released it starts over from the front. Creating a buffer and destroying
// it each cost the driver a kernel call of a tenth of a millisecond or more,
// so a join's thousands of uploads share a few blocks instead.
struct StagingBlock {
VkDevice device = VK_NULL_HANDLE;
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
VkDeviceSize capacity = 0;
std::uint8_t* mapped = nullptr;
// First byte not yet handed out, and the ranges command buffers hold.
VkDeviceSize head = 0;
std::size_t live = 0;
};
// Idle staging buffers kept for reuse. Creating, allocating, binding and
// mapping one costs hundreds of microseconds in the driver, so a burst of
// texture uploads would otherwise stall the frame that records it.
constexpr VkDeviceSize kMaxIdleStagingBytes = 64 * 1024 * 1024;
constexpr std::size_t kMaxIdleStagingBuffers = 64;
// One recorded copy's range of a staging block.
struct Staging {
StagingBlock* block = nullptr;
VkDeviceSize offset = 0;
};
// Ranges start on this boundary. It meets the bufferOffset rule of every
// host format and keeps ranges written by different threads off shared cache
// lines.
constexpr VkDeviceSize kStagingAlignment = 256;
// Empty blocks kept for reuse, counted in block sizes; the rest are
// destroyed.
constexpr VkDeviceSize kIdleStagingBlocks = 4;
// Decode batches kept for their buffers once they complete.
constexpr std::size_t kMaxSpareBatches = 8;
@@ -247,9 +262,6 @@ class RawBuffer {
std::unique_ptr<std::uint8_t[]> data_;
std::size_t size_ = 0;
};
// A request may take an idle buffer up to this size, or up to 4x its own
// size when larger, so small uploads share buffers without pinning big ones.
constexpr VkDeviceSize kStagingReuseSlack = 64 * 1024;
struct PendingUpload {
VkDevice device = VK_NULL_HANDLE;
@@ -327,9 +339,8 @@ void LogFailure(const char* message) {
}
}
bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
std::uint8_t** mapped) {
*mapped = nullptr;
bool CreateStagingBlock(const HostDevice& dev, VkDeviceSize size,
StagingBlock* block) {
if (dev.create_buffer == nullptr || dev.allocate_memory == nullptr ||
dev.get_buffer_memory_requirements == nullptr ||
dev.bind_buffer_memory == nullptr || dev.map_memory == nullptr ||
@@ -379,12 +390,14 @@ bool CreateStaging(const HostDevice& dev, VkDeviceSize size, Staging* staging,
dev.free_memory(dev.device, memory, nullptr);
return false;
}
*mapped = static_cast<std::uint8_t*>(data);
*staging = {dev.device, buffer, memory, size, *mapped};
block->device = dev.device;
block->buffer = buffer;
block->memory = memory;
block->capacity = size;
block->mapped = static_cast<std::uint8_t*>(data);
return true;
}
// Freeing mapped memory unmaps it implicitly.
// Packs each upload's compressed bytes into `destination`, dropping the row
// and layer padding the application may have asked for. Sources are parallel
// to `uploads`.
@@ -434,12 +447,13 @@ void RecordHostWriteBarrier(const HostDevice& dev,
nullptr, 0, nullptr);
}
void DestroyStaging(const HostDevice& dev, const Staging& staging) {
// Freeing mapped memory unmaps it implicitly.
void DestroyStagingBlock(const HostDevice& dev, const StagingBlock& block) {
if (dev.destroy_buffer != nullptr) {
dev.destroy_buffer(dev.device, staging.buffer, nullptr);
dev.destroy_buffer(dev.device, block.buffer, nullptr);
}
if (dev.free_memory != nullptr) {
dev.free_memory(dev.device, staging.memory, nullptr);
dev.free_memory(dev.device, block.memory, nullptr);
}
}
@@ -603,6 +617,9 @@ bool PlanRegions(const ImageRecord& record, VkDevice device, VkBuffer source,
} // namespace
struct VulkanEtc2Emulation::State {
explicit State(VkDeviceSize block_bytes)
: staging_block_bytes(block_bytes) {}
std::mutex mutex;
std::mutex devices_mutex;
std::unordered_map<VkPhysicalDevice, bool> physical_devices;
@@ -612,8 +629,10 @@ struct VulkanEtc2Emulation::State {
std::unordered_map<VkDeviceMemory, Mapping> mappings;
std::unordered_map<VkCommandBuffer, CommandRecord> commands;
std::atomic<bool> has_commands{false};
std::vector<Staging> idle_staging;
VkDeviceSize idle_staging_bytes = 0;
// Blocks of every device, in creation order. Guarded by `mutex`; a block's
// address is stable until it is destroyed.
const VkDeviceSize staging_block_bytes;
std::vector<std::unique_ptr<StagingBlock>> staging_blocks;
// Heap buffers reused across submits; they only grow.
std::mutex scratch_mutex;
RawBuffer scratch_source;
@@ -765,51 +784,78 @@ struct VulkanEtc2Emulation::State {
static_cast<unsigned>(threads));
}
// Takes the smallest idle buffer that fits, or creates one.
// Takes `size` bytes from the first block of the device with room for
// them, or from a new block of at least `staging_block_bytes`.
bool AcquireStaging(const HostDevice& dev, VkDeviceSize size,
Staging* staging) {
const VkDeviceSize aligned =
(size + kStagingAlignment - 1) / kStagingAlignment * kStagingAlignment;
{
std::lock_guard<std::mutex> lock(mutex);
const VkDeviceSize limit = std::max(size * 4, kStagingReuseSlack);
auto best = idle_staging.end();
for (auto it = idle_staging.begin(); it != idle_staging.end(); ++it) {
if (it->device == dev.device && it->capacity >= size &&
it->capacity <= limit &&
(best == idle_staging.end() || it->capacity < best->capacity)) {
best = it;
for (const auto& block : staging_blocks) {
if (block->device == dev.device &&
block->capacity - block->head >= aligned) {
*staging = {block.get(), block->head};
block->head += aligned;
++block->live;
return true;
}
}
if (best != idle_staging.end()) {
*staging = *best;
idle_staging_bytes -= best->capacity;
*best = idle_staging.back();
idle_staging.pop_back();
return true;
}
// Created outside the lock, which the decode workers also take.
auto block = std::make_unique<StagingBlock>();
{
const VkDeviceSize capacity = std::max(staging_block_bytes, aligned);
TraceScope trace_scope(ActiveProfileTrace(), "etc2 staging create",
"texture");
trace_scope.Arg("bytes", static_cast<std::int64_t>(capacity));
if (!CreateStagingBlock(dev, capacity, block.get())) {
return false;
}
}
std::uint8_t* mapped = nullptr;
return CreateStaging(dev, size, staging, &mapped);
block->head = aligned;
block->live = 1;
*staging = {block.get(), 0};
std::lock_guard<std::mutex> lock(mutex);
staging_blocks.push_back(std::move(block));
return true;
}
// Keeps released buffers for reuse up to the idle limits and destroys
// the rest.
void ReleaseStaging(std::vector<Staging> released) {
std::vector<Staging> doomed;
// Returns ranges to their blocks. A block with no ranges left starts over
// from its front; empty blocks beyond kIdleStagingBlocks block sizes are
// destroyed.
void ReleaseStaging(const std::vector<Staging>& released) {
std::vector<std::unique_ptr<StagingBlock>> doomed;
{
std::lock_guard<std::mutex> lock(mutex);
for (const Staging& staging : released) {
if (idle_staging.size() < kMaxIdleStagingBuffers &&
idle_staging_bytes + staging.capacity <= kMaxIdleStagingBytes) {
idle_staging.push_back(staging);
idle_staging_bytes += staging.capacity;
} else {
doomed.push_back(staging);
if (--staging.block->live == 0) {
staging.block->head = 0;
}
}
const VkDeviceSize idle_limit = kIdleStagingBlocks * staging_block_bytes;
VkDeviceSize idle_bytes = 0;
for (auto it = staging_blocks.begin(); it != staging_blocks.end();) {
if ((*it)->live == 0 && idle_bytes + (*it)->capacity > idle_limit) {
doomed.push_back(std::move(*it));
it = staging_blocks.erase(it);
continue;
}
if ((*it)->live == 0) {
idle_bytes += (*it)->capacity;
}
++it;
}
}
for (const Staging& staging : doomed) {
if (const HostDevice* dev = Find(staging.device); dev != nullptr) {
DestroyStaging(*dev, staging);
if (doomed.empty()) {
return;
}
TraceScope trace_scope(ActiveProfileTrace(), "etc2 staging destroy",
"texture");
trace_scope.Arg("buffers", static_cast<std::int64_t>(doomed.size()));
for (const auto& block : doomed) {
if (const HostDevice* dev = Find(block->device); dev != nullptr) {
DestroyStagingBlock(*dev, *block);
}
}
}
@@ -1043,7 +1089,8 @@ struct VulkanEtc2Emulation::State {
}
};
VulkanEtc2Emulation::VulkanEtc2Emulation() : state_(new State) {}
VulkanEtc2Emulation::VulkanEtc2Emulation(VkDeviceSize staging_block_bytes)
: state_(new State(staging_block_bytes)) {}
VulkanEtc2Emulation::~VulkanEtc2Emulation() {
state_->StopDispatcher();
@@ -1163,27 +1210,22 @@ void VulkanEtc2Emulation::RegisterDevice(
void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
// Nothing may free staging or the semaphore while a decode still writes it.
state_->DrainDecodes();
std::vector<Staging> doomed;
std::vector<std::unique_ptr<StagingBlock>> doomed;
{
std::lock_guard<std::mutex> lock(state_->mutex);
for (auto it = state_->commands.begin(); it != state_->commands.end();) {
const bool owned = std::any_of(
it->second.staging.begin(), it->second.staging.end(),
[device](const Staging& staging) { return staging.device == device; });
if (owned) {
doomed.insert(doomed.end(), it->second.staging.begin(),
it->second.staging.end());
it = state_->commands.erase(it);
} else {
++it;
}
[device](const Staging& staging) {
return staging.block->device == device;
});
it = owned ? state_->commands.erase(it) : std::next(it);
}
for (auto it = state_->idle_staging.begin();
it != state_->idle_staging.end();) {
if (it->device == device) {
state_->idle_staging_bytes -= it->capacity;
doomed.push_back(*it);
it = state_->idle_staging.erase(it);
auto& blocks = state_->staging_blocks;
for (auto it = blocks.begin(); it != blocks.end();) {
if ((*it)->device == device) {
doomed.push_back(std::move(*it));
it = blocks.erase(it);
} else {
++it;
}
@@ -1195,8 +1237,8 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
std::memory_order_release);
}
if (const HostDevice* dev = state_->Find(device); dev != nullptr) {
for (const Staging& staging : doomed) {
DestroyStaging(*dev, staging);
for (const auto& block : doomed) {
DestroyStagingBlock(*dev, *block);
}
if (dev->timeline != VK_NULL_HANDLE && dev->destroy_semaphore != nullptr) {
dev->destroy_semaphore(device, dev->timeline, nullptr);
@@ -1442,11 +1484,14 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage(
return;
}
for (PendingUpload& upload : uploads) {
upload.target = staging.mapped + upload.target_offset;
upload.target = staging.block->mapped + staging.offset + upload.target_offset;
}
for (VkBufferImageCopy& region : rewritten) {
region.bufferOffset += staging.offset;
}
state_->Record(command_buffer, std::move(uploads), staging);
RecordHostWriteBarrier(*dev, command_buffer);
dev->copy_buffer_to_image(command_buffer, staging.buffer, destination,
dev->copy_buffer_to_image(command_buffer, staging.block->buffer, destination,
layout, region_count, rewritten.data());
}
@@ -1478,12 +1523,15 @@ void VulkanEtc2Emulation::CmdCopyBufferToImage2(
return;
}
for (PendingUpload& upload : uploads) {
upload.target = staging.mapped + upload.target_offset;
upload.target = staging.block->mapped + staging.offset + upload.target_offset;
}
for (VkBufferImageCopy2& region : rewritten) {
region.bufferOffset += staging.offset;
}
state_->Record(command_buffer, std::move(uploads), staging);
RecordHostWriteBarrier(*dev, command_buffer);
VkCopyBufferToImageInfo2 host_info = *info;
host_info.srcBuffer = staging.buffer;
host_info.srcBuffer = staging.block->buffer;
host_info.pRegions = rewritten.data();
dev->copy_buffer_to_image2(command_buffer, &host_info);
}
@@ -1834,7 +1882,18 @@ void VulkanEtc2Emulation::ReleaseCommandBuffer(VkCommandBuffer command_buffer) {
ticket = record->second.last_ticket;
}
// Waited on without holding the state lock, which the decode itself takes.
// Only waits long enough to matter are traced; this runs for every
// command buffer the application resets.
ChromeTraceWriter* const trace = ActiveProfileTrace();
const std::uint64_t wait_start_ns = trace != nullptr ? TraceClockNanos() : 0;
state_->WaitForTicket(ticket);
if (trace != nullptr) {
const std::uint64_t wait_end_ns = TraceClockNanos();
if (wait_end_ns - wait_start_ns >= 100'000) {
trace->Slice("etc2 release wait", "texture", wait_start_ns, wait_end_ns,
{{"ticket", static_cast<std::int64_t>(ticket)}});
}
}
std::vector<Staging> doomed;
{
+5 -1
View File
@@ -1650,9 +1650,13 @@ jlong ParseLocalStorageUserIdFromEnv() {
if (!value || value[0] == '\0') {
return 0;
}
errno = 0;
char* end = nullptr;
long long parsed = std::strtoll(value, &end, 10);
return end == value ? 0 : static_cast<jlong>(parsed);
if (errno == ERANGE || end == value || *end != '\0') {
return 0;
}
return static_cast<jlong>(parsed);
}
jlong CurrentLocalStorageUserLocked() {
+68 -10
View File
@@ -37,7 +37,9 @@
#include <fcntl.h>
#include <unistd.h>
#include <sys/prctl.h>
#if defined(__x86_64__)
#include <asm/prctl.h>
#endif
#include <sys/syscall.h>
#include <vector>
@@ -50,6 +52,7 @@
#include "compat/bionic_socket_runtime.h"
#include "compat/build_profile.h"
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
#include "compat/host_abi_experiment.h"
#include "compat/host_abi_profile.h"
#include "compat/host_allocator_bridge.h"
@@ -1046,6 +1049,15 @@ void PumpRobloxMainThreadMessagesOnce() {
mocktail::graphics::ChromeTraceWriter* trace =
hooks.active != nullptr ? hooks.active() : nullptr;
const std::uint64_t pump_start_ns = trace != nullptr ? hooks.clock() : 0;
// CPU time separates a step that computes from one that waits.
const auto thread_cpu_ns = [] {
timespec now{};
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now);
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
static_cast<std::uint64_t>(now.tv_nsec);
};
const std::uint64_t pump_start_cpu_ns =
trace != nullptr ? thread_cpu_ns() : 0;
g_native_call_messages_from_main_thread(
env, g_native_gl_class_for_main_thread);
if (trace != nullptr) {
@@ -1053,8 +1065,11 @@ void PumpRobloxMainThreadMessagesOnce() {
// calls that ran a message are recorded.
const std::uint64_t pump_end_ns = hooks.clock();
if (pump_end_ns - pump_start_ns >= 20'000) {
const mocktail::graphics::TraceArg args[] = {
{"cpu_us", static_cast<std::int64_t>(
(thread_cpu_ns() - pump_start_cpu_ns) / 1000)}};
hooks.slice(trace, "nativeCallMessagesFromMainThread", "pump",
pump_start_ns, pump_end_ns, nullptr, 0);
pump_start_ns, pump_end_ns, args, 1);
}
}
if (__builtin_expect(trace_pump, 0)) {
@@ -1096,7 +1111,7 @@ struct BionicAddrInfo {
};
static_assert(sizeof(BionicAddrInfo) == 48,
"unexpected x86_64 bionic addrinfo size");
"unexpected bionic addrinfo size");
bool DnsTraceEnabled() {
return IsEnabled("MOCKTAIL_DNS_TRACE");
@@ -1461,15 +1476,31 @@ bool IsUnsafeSoftTimeoutModule(void* rip) {
void JniOnLoadTimeoutAlarm(int, siginfo_t* info, void* context) {
static_cast<void>(info);
auto* uc = static_cast<ucontext_t*>(context);
#if defined(__x86_64__)
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RIP]) : 0;
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RSP]) : 0;
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RBP]) : 0;
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RAX]) : 0;
const char* regs_format =
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
"RAX=0x%016llx\n";
#elif defined(__aarch64__)
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.pc) : 0;
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.sp) : 0;
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[29]) : 0;
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[0]) : 0;
const char* regs_format =
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
"X0=0x%016llx\n";
#else
uintptr_t rip = 0, rsp = 0, rbp = 0, rax = 0;
const char* regs_format =
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
"X0=0x%016llx\n";
#endif
char regs_msg[192];
int len = std::snprintf(
regs_msg, sizeof(regs_msg),
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
"RAX=0x%016llx\n",
regs_msg, sizeof(regs_msg), regs_format,
static_cast<unsigned long long>(rip), static_cast<unsigned long long>(rsp),
static_cast<unsigned long long>(rbp), static_cast<unsigned long long>(rax));
write(2, regs_msg, static_cast<size_t>(len));
@@ -2517,10 +2548,12 @@ jobject BuildDeviceParams(JNIEnv* env) {
GetEnvString("MOCKTAIL_DEVICE_NAME", "Mocktail Linux");
const std::string manufacturer =
GetEnvString("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
const std::string device_sku =
GetEnvString("MOCKTAIL_DEVICE_SKU", "mocktail-x86_64");
const std::string device_sku = GetEnvString(
"MOCKTAIL_DEVICE_SKU",
("mocktail-" + std::string(mocktail::compat::kGuestCpuName)).c_str());
const std::string soc_model =
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL", "x86_64");
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL",
std::string(mocktail::compat::kGuestCpuName).c_str());
SetStringField(env, params, "osVersion", "33");
SetStringField(env, params, "deviceName", device_name.c_str());
const std::string app_version =
@@ -4718,12 +4751,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
// Roblox internal threads trigger SI_KERNEL traps from CET shadow-stack
// return mismatches. Unsupported kernels ignore this request.
#if defined(__x86_64__)
{
long r = syscall(SYS_arch_prctl, ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK);
if (r == 0) {
std::cout << " [cet] shadow-stack (SHSTK) disabled\n";
}
}
#endif
if (build_profile.allow_legacy_binary_patches) {
std::cerr
@@ -5643,6 +5678,27 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
reinterpret_cast<void*>(mocktail_pthread_spin_trylock));
linker::RegisterSymbol("pthread_spin_unlock",
reinterpret_cast<void*>(mocktail_pthread_spin_unlock));
linker::RegisterSymbol(
"pthread_attr_init",
reinterpret_cast<void*>(mocktail_pthread_attr_init));
linker::RegisterSymbol(
"pthread_attr_destroy",
reinterpret_cast<void*>(mocktail_pthread_attr_destroy));
linker::RegisterSymbol(
"pthread_attr_setstacksize",
reinterpret_cast<void*>(mocktail_pthread_attr_setstacksize));
linker::RegisterSymbol(
"pthread_attr_setdetachstate",
reinterpret_cast<void*>(mocktail_pthread_attr_setdetachstate));
linker::RegisterSymbol(
"pthread_attr_setschedparam",
reinterpret_cast<void*>(mocktail_pthread_attr_setschedparam));
linker::RegisterSymbol(
"pthread_getattr_np",
reinterpret_cast<void*>(mocktail_pthread_getattr_np));
linker::RegisterSymbol(
"pthread_attr_getstack",
reinterpret_cast<void*>(mocktail_pthread_attr_getstack));
linker::RegisterSymbol("pthread_barrier_init",
reinterpret_cast<void*>(mocktail_pthread_barrier_init));
linker::RegisterSymbol(
@@ -5762,7 +5818,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (roblox_handle == nullptr) {
std::cerr << "\n[FATAL] Could not load '" << library_path << "'.\n"
<< " Extract lib/x86_64/libroblox.so from a Roblox APK and\n"
<< " Extract lib/" << compat::kGuestAbi
<< "/libroblox.so from a Roblox APK and\n"
<< " place it at the path above (or set ROBLOX_LIB_PATH).\n";
return EXIT_FAILURE;
}
@@ -6665,7 +6722,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
mocktail::runtime::RobloxExperienceSurfaceProvider{},
discord_rpc.observer(),
dependencies.clear_persisted_web_view_cookie(),
runtime_config.microphone_enabled());
runtime_config.microphone_enabled(),
platform_web_symbols.account);
const mocktail::Status platform_protocol_status =
experience_composition->InitializePlatformProtocols();
if (!platform_protocol_status.ok()) {
+5 -10
View File
@@ -74,16 +74,11 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source,
SDL_GetWindowRelativeMouseMode(window)};
return true;
case SDL_EVENT_MOUSE_WHEEL: {
float delta_x = source.wheel.x;
float delta_y = source.wheel.y;
if (source.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) {
delta_x = -delta_x;
delta_y = -delta_y;
}
destination->payload =
MouseWheelEvent{delta_x, delta_y, source.wheel.mouse_x,
source.wheel.mouse_y,
SDL_GetWindowRelativeMouseMode(window)};
// SDL already applies the host's natural-scrolling preference to x/y.
// Undoing SDL_MOUSEWHEEL_FLIPPED here would ignore the OS setting.
destination->payload = MouseWheelEvent{
source.wheel.x, source.wheel.y, source.wheel.mouse_x,
source.wheel.mouse_y, SDL_GetWindowRelativeMouseMode(window)};
return true;
}
case SDL_EVENT_FINGER_DOWN:
+5 -3
View File
@@ -6,6 +6,7 @@
#include <string>
#include <string_view>
#include "compat/guest_abi.h"
#include "runtime/roblox_launch_uri.h"
namespace mocktail {
@@ -262,11 +263,12 @@ std::string CommandLineUsage(const std::string& program_name) {
usage
<< "Usage: " << (program_name.empty() ? "mocktail" : program_name)
<< " [options] [roblox-uri]\n\n"
<< "Normal startup downloads and activates a verified managed x86_64 "
"Roblox payload automatically.\n\n"
<< "Normal startup downloads and activates a verified managed "
<< compat::kGuestAbi
<< " Roblox payload automatically.\n\n"
<< "Options:\n"
<< " --roblox-lib <path> Development override for a specific "
"x86_64 libroblox.so\n"
<< compat::kGuestAbi << " libroblox.so\n"
<< " --headless Run without creating an SDL window\n"
<< " --windowed Force windowed startup (default)\n"
<< " --graphics <backend> direct-vulkan | opengl | system | "
+18 -2
View File
@@ -1,10 +1,13 @@
#include "runtime/engine_pump_rest.h"
#include <cpuid.h>
#include <immintrin.h>
#include <sys/prctl.h>
#include <time.h>
#if defined(__x86_64__)
#include <cpuid.h>
#include <immintrin.h>
#include <x86intrin.h>
#endif
#include <cstdio>
#include <cstdlib>
@@ -23,11 +26,20 @@ uint64_t NowNs() {
// The kernel caps one TPAUSE at umwait_control/max_time TSC cycles (100000 by
// default), so the wait is a loop of short pauses up to the deadline. Control
// bit 0 clear selects C0.2, the deeper of the two light states.
#if defined(__x86_64__)
__attribute__((target("waitpkg"))) void TpauseUntil(uint64_t deadline_ns) {
while (NowNs() < deadline_ns) {
_tpause(0, __rdtsc() + 100000ULL);
}
}
#else
// TPAUSE is x86-only. CpuHasWaitPkg() is false on other hosts, so kTpause is
// never chosen there; this waits out the deadline if it is forced anyway.
void TpauseUntil(uint64_t deadline_ns) {
while (NowNs() < deadline_ns) {
}
}
#endif
void LowerTimerSlackOnce() {
// Only the main thread rests, so the slack is set once per process.
@@ -88,11 +100,15 @@ bool CpuGovernorIsPerformance(const char* path) {
}
bool CpuHasWaitPkg() {
#if defined(__x86_64__)
unsigned eax = 0, ebx = 0, ecx = 0, edx = 0;
if (__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx) == 0) {
return false;
}
return (ecx & (1u << 5)) != 0;
#else
return false;
#endif
}
EnginePumpRestMode ActiveEnginePumpRestMode() {
+9
View File
@@ -29,12 +29,21 @@ constexpr const char* kIcdDirectories[] = {
"/usr/local/share/vulkan/icd.d",
};
#if defined(__aarch64__)
constexpr char kNativeArchitecture[] = "aarch64";
constexpr const char* kForeignArchitectures[] = {
"i686", "i586", "i486", "i386", "x86_64", "x86.",
"amd64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
};
#else
constexpr char kNativeArchitecture[] = "x86_64";
constexpr const char* kForeignArchitectures[] = {
"i686", "i586", "i486", "i386", "x86.", "aarch64",
"arm64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
};
#endif
struct HostGpus {
bool intel = false;
+239
View File
@@ -0,0 +1,239 @@
#include "runtime/roblox_account_bridge.h"
#include <array>
#include <condition_variable>
#include <cstdio>
#include <iterator>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
constexpr char kAccountProtocolClass[] =
"com/roblox/universalapp/account/JNIAccountProtocol";
// Serialized name of the APK status enum for a missing Play token provider.
constexpr char kTokenProviderUninitialized[] = "TOKEN_PROVIDER_UNINITIALIZED";
constexpr std::size_t kMaximumNameBytes = 128;
enum Method : std::size_t { kDeviceIntegrityAvailable, kGetIntegrityToken };
constexpr std::size_t kMethodCount = 2;
constexpr const char* kMethodLabels[kMethodCount] = {"DeviceIntegrityAvailable",
"GetIntegrityToken"};
Status Unavailable(const char* message) {
return Status::Error(StatusCode::kUnavailable, message);
}
Status CheckJni(JNIEnv* env) {
if (!env->ExceptionCheck()) return Status::Ok();
env->ExceptionClear();
return Unavailable("AccountProtocol JNI operation failed");
}
// Copies a non-empty, bounded engine-provided name.
bool ReadName(JNIEnv* env, jstring value, std::string* name) {
if (!value) return false;
const jsize size = env->GetStringUTFLength(value);
const char* bytes = env->GetStringUTFChars(value, nullptr);
const bool valid = bytes && size > 0 &&
static_cast<std::size_t>(size) <= kMaximumNameBytes;
if (valid) name->assign(bytes, static_cast<std::size_t>(size));
if (bytes) env->ReleaseStringUTFChars(value, bytes);
return valid;
}
// Pins a valid engine-provided name as a global reference.
jstring RetainName(JNIEnv* env, jstring value) {
std::string ignored;
return ReadName(env, value, &ignored)
? static_cast<jstring>(env->NewGlobalRef(value))
: nullptr;
}
} // namespace
struct RobloxAccountBridge::State {
struct Endpoint {
std::shared_ptr<State> state;
Method method;
};
struct Registration {
jobject handler = nullptr;
jstring method = nullptr;
bool registered = false;
};
jobject bus = nullptr;
jstring protocol = nullptr;
std::string support_key;
std::string token_key;
std::string result_key;
std::array<Registration, kMethodCount> registrations;
std::mutex mutex;
std::condition_variable drained;
bool accepting = true;
std::size_t in_flight = 0;
// The request payload is never parsed or logged: both answers are fixed.
static std::string Answer(void* context, JNIEnv*, jstring) {
const auto* endpoint = static_cast<Endpoint*>(context);
const auto state = endpoint->state;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->accepting) return {};
++state->in_flight;
}
struct Finish {
std::shared_ptr<State> state;
~Finish() {
std::lock_guard<std::mutex> lock(state->mutex);
--state->in_flight;
state->drained.notify_all();
}
} finish{state};
nlohmann::json body;
const char* result = nullptr;
if (endpoint->method == kDeviceIntegrityAvailable) {
body = {{state->support_key, false}};
result = "unsupported";
} else {
body = {{state->token_key, ""},
{state->result_key, kTokenProviderUninitialized}};
result = kTokenProviderUninitialized;
}
std::fprintf(stderr, " [account] AccountProtocol method=%s result=%s\n",
kMethodLabels[endpoint->method], result);
return body.dump();
}
};
RobloxAccountBridge::RobloxAccountBridge(JniEnvironmentProvider environment,
RobloxAccountProtocolSymbols symbols,
RobloxAccountProtocolObjects objects)
: environment_(environment), symbols_(symbols), objects_(objects) {}
RobloxAccountBridge::~RobloxAccountBridge() { (void)Shutdown(); }
Status RobloxAccountBridge::Initialize() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
if (state_) {
return Status::Error(StatusCode::kFailedPrecondition,
"AccountProtocol is already initialized");
}
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
return Unavailable("AccountProtocol prerequisites are incomplete");
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
jclass protocol_class = env->FindClass(kAccountProtocolClass);
if (!protocol_class) {
(void)CheckJni(env);
return Unavailable("JNIAccountProtocol class is unavailable");
}
const jstring protocol = symbols_.get_protocol_name(env, protocol_class);
const jstring methods[kMethodCount] = {
symbols_.get_device_integrity_available_method_name(env, protocol_class),
symbols_.get_integrity_token_method_name(env, protocol_class)};
const jstring keys[] = {symbols_.get_support_key(env, protocol_class),
symbols_.get_token_key(env, protocol_class),
symbols_.get_result_key(env, protocol_class)};
env->DeleteLocalRef(protocol_class);
state_ = std::make_shared<State>();
std::string* const key_targets[] = {&state_->support_key,
&state_->token_key, &state_->result_key};
bool names_valid = true;
for (std::size_t i = 0; i < std::size(keys); ++i) {
names_valid = ReadName(env, keys[i], key_targets[i]) && names_valid;
if (keys[i]) env->DeleteLocalRef(keys[i]);
}
state_->protocol = RetainName(env, protocol);
if (protocol) env->DeleteLocalRef(protocol);
for (std::size_t i = 0; i < kMethodCount; ++i) {
state_->registrations[i].method = RetainName(env, methods[i]);
names_valid = state_->registrations[i].method && names_valid;
if (methods[i]) env->DeleteLocalRef(methods[i]);
}
state_->bus = env->NewGlobalRef(objects_.message_bus);
auto fail = [this](Status failure) {
(void)ShutdownLocked();
return failure;
};
if (!CheckJni(env).ok() || !names_valid || !state_->protocol ||
!state_->bus) {
return fail(Unavailable("AccountProtocol names are unavailable"));
}
for (std::size_t i = 0; i < kMethodCount; ++i) {
auto& entry = state_->registrations[i];
auto endpoint = std::make_shared<State::Endpoint>(
State::Endpoint{state_, static_cast<Method>(i)});
jobject handler = objects_.create_request_handler(objects_.context,
endpoint, &State::Answer);
if (handler) {
entry.handler = env->NewGlobalRef(handler);
if (!entry.handler) {
objects_.clear_request_handler(objects_.context, handler);
}
env->DeleteLocalRef(handler);
}
if (!entry.handler || !CheckJni(env).ok()) {
return fail(Unavailable("could not create AccountProtocol handler"));
}
entry.registered = true;
symbols_.set_request_handler_raw(env, state_->bus, state_->protocol,
entry.method, entry.handler);
status = CheckJni(env);
if (!status.ok()) return fail(status);
}
std::fprintf(stderr,
" [account] AccountProtocol ready device_integrity=unsupported\n");
return Status::Ok();
}
Status RobloxAccountBridge::Shutdown() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
return ShutdownLocked();
}
Status RobloxAccountBridge::ShutdownLocked() {
if (!state_) return Status::Ok();
{
std::unique_lock<std::mutex> lock(state_->mutex);
state_->accepting = false;
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
// Clear any exception from partially failed initialization before cleanup.
const Status pending = CheckJni(env);
if (!pending.ok()) status = pending;
for (auto& entry : state_->registrations) {
if (entry.registered) {
symbols_.clear_request_handler(env, state_->bus, state_->protocol,
entry.method);
const Status cleared = CheckJni(env);
if (!cleared.ok()) status = cleared;
}
if (entry.handler) {
objects_.clear_request_handler(objects_.context, entry.handler);
env->DeleteGlobalRef(entry.handler);
}
if (entry.method) env->DeleteGlobalRef(entry.method);
entry = {};
}
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
if (state_->bus) env->DeleteGlobalRef(state_->bus);
state_.reset();
return status;
}
} // namespace runtime
} // namespace mocktail
+135 -13
View File
@@ -11,6 +11,7 @@
#include "jnivm/jnivm.h"
#include "runtime/external_launch_broker.h"
#include "runtime/roblox_launch_uri.h"
#include "runtime/webview_helper_launcher.h"
#include "window/window.h"
@@ -21,7 +22,13 @@ namespace {
constexpr size_t kMaxPendingLaunchRequests = 8;
// libroblox's static TLS requires the proven 16 MiB guest-thread stack floor.
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000};
// Covers helper exec through window creation, including cold Flatpak starts.
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000};
// Roblox closes a solved WebViewProtocol challenge itself with closeWindow,
// as on Android. The host closes it only if Roblox has not done so within
// this period. Captcha windows close at once instead (see
// RobloxWebViewOpenRequest::close_when_challenge_solved).
constexpr std::chrono::milliseconds kSolvedChallengeCloseGrace{3000};
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
Status Invalid(std::string message) {
@@ -158,24 +165,33 @@ Status LaunchRobloxWebSurface(
}
std::fprintf(stderr, " [%s] opening validated Roblox web surface\n",
transport);
const auto started = std::chrono::steady_clock::now();
const WebViewHelperLaunchResult launched =
LaunchWebViewHelper(helper, url, std::move(exit_observer));
if (!launched) {
return Unavailable("could not display Roblox web surface: " +
launched.error);
}
if (launched.process == nullptr ||
!launched.process->WaitUntilReady(kWebSurfaceReadyTimeout)) {
const bool ready = launched.process != nullptr &&
launched.process->WaitUntilReady(kWebSurfaceReadyTimeout);
const long long elapsed_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started)
.count();
if (!ready) {
if (launched.process != nullptr) {
(void)launched.process->RequestClose();
}
return Unavailable(
"Roblox web surface did not become ready before the deadline");
"Roblox web surface did not become ready before the deadline (" +
std::to_string(elapsed_ms) + " ms of " +
std::to_string(kWebSurfaceReadyTimeout.count()) + " ms)");
}
if (process != nullptr) {
*process = launched.process;
}
std::fprintf(stderr, " [%s] Roblox web surface launched\n", transport);
std::fprintf(stderr, " [%s] Roblox web surface launched in %lld ms\n",
transport, elapsed_ms);
return Status::Ok();
}
@@ -299,12 +315,14 @@ RobloxExperienceComposition::RobloxExperienceComposition(
const SecureRobloxCredential* initial_web_view_credential,
RobloxExperienceSurfaceProvider surface_provider,
RobloxExperiencePresenceObserver presence_observer,
bool clear_persisted_web_view_cookie, bool microphone_enabled)
bool clear_persisted_web_view_cookie, bool microphone_enabled,
RobloxAccountProtocolSymbols account_symbols)
: environment_(environment),
message_bus_symbols_(message_bus_symbols),
web_view_symbols_(web_view_symbols),
browser_service_symbols_(browser_service_symbols),
permissions_symbols_(permissions_symbols),
account_symbols_(account_symbols),
microphone_enabled_(microphone_enabled),
game_symbols_(game_symbols),
jni_factory_(jni_factory),
@@ -425,11 +443,33 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
(void)ReleaseGlobalObjects();
return status;
}
// AccountProtocol only shortens a device-integrity login challenge that
// cannot succeed here, so its absence or failure never blocks startup.
std::unique_ptr<RobloxAccountBridge> account_bridge;
if (account_symbols_.complete()) {
account_bridge = std::make_unique<RobloxAccountBridge>(
environment_, account_symbols_,
RobloxAccountProtocolObjects{web_view_objects.message_bus,
jni_factory_.context,
jni_factory_.create_request_handler,
jni_factory_.clear_request_handler});
const Status account_status = account_bridge->Initialize();
if (!account_status.ok()) {
std::fprintf(stderr, " [account] AccountProtocol unavailable: %s\n",
account_status.message().c_str());
account_bridge.reset();
}
} else {
std::fprintf(stderr,
" [account] AccountProtocol entrypoints are missing; "
"device-integrity challenges wait for Roblox's timeout\n");
}
{
std::lock_guard<std::mutex> lock(mutex_);
web_view_bridge_ = std::move(web_view_bridge);
browser_service_bridge_ = std::move(browser_service_bridge);
permissions_bridge_ = std::move(permissions_bridge);
account_bridge_ = std::move(account_bridge);
platform_protocols_initialized_ = true;
}
std::fprintf(stderr,
@@ -602,6 +642,8 @@ Status RobloxExperienceComposition::OpenWebSurface(
web_surface_process_generation_ = process_generation;
web_surface_route_ = route;
web_surface_logical_generation_ = logical_generation;
web_surface_close_when_challenge_solved_ =
presentation.close_when_challenge_solved;
web_surface_logical_exit_observer_ = std::move(exit_observer);
}
if (superseded_observer.valid()) {
@@ -614,13 +656,16 @@ Status RobloxExperienceComposition::OpenWebSurface(
return Status::Ok();
}
Status RobloxExperienceComposition::CloseWebSurface() {
Status RobloxExperienceComposition::CloseWebSurface(
uint64_t expected_logical_generation) {
std::lock_guard<std::mutex> operation_lock(web_surface_operation_mutex_);
std::shared_ptr<WebViewHelperProcess> process;
WebViewHelperExitObserver exit_observer;
{
std::lock_guard<std::mutex> lock(mutex_);
if (web_surface_route_ == WebSurfaceRoute::kNone) {
if (web_surface_route_ == WebSurfaceRoute::kNone ||
(expected_logical_generation != 0 &&
expected_logical_generation != web_surface_logical_generation_)) {
return Status::Ok();
}
process = std::move(web_surface_process_);
@@ -628,6 +673,7 @@ Status RobloxExperienceComposition::CloseWebSurface() {
exit_observer = std::move(web_surface_logical_exit_observer_);
web_surface_route_ = WebSurfaceRoute::kNone;
web_surface_logical_generation_ = 0;
web_surface_close_when_challenge_solved_ = false;
}
// A closed challenge must stop executing. Reusing its hidden document lets
// late callbacks from the old attempt reach the next login attempt. The APK
@@ -643,6 +689,33 @@ Status RobloxExperienceComposition::CloseWebSurface() {
return Status::Ok();
}
Status RobloxExperienceComposition::CloseUnclaimedSolvedChallenge() {
uint64_t generation = 0;
{
std::lock_guard<std::mutex> lock(mutex_);
if (!solved_challenge_close_deadline_.has_value()) {
return Status::Ok();
}
const bool still_open =
web_surface_route_ != WebSurfaceRoute::kNone &&
solved_challenge_logical_generation_ == web_surface_logical_generation_;
if (still_open &&
std::chrono::steady_clock::now() < *solved_challenge_close_deadline_) {
return Status::Ok();
}
solved_challenge_close_deadline_.reset();
if (!still_open) {
return Status::Ok();
}
generation = solved_challenge_logical_generation_;
}
std::fprintf(stderr,
" [webview] closing solved challenge window; Roblox did not "
"close it within %lld ms\n",
static_cast<long long>(kSolvedChallengeCloseGrace.count()));
return CloseWebSurface(generation);
}
void RobloxExperienceComposition::WebSurfaceExited(void* context) {
auto* exit_context = static_cast<WebSurfaceExitContext*>(context);
if (exit_context == nullptr || exit_context->target == nullptr ||
@@ -668,6 +741,7 @@ void RobloxExperienceComposition::HandleWebSurfaceExit(
web_surface_process_generation_ = 0;
web_surface_route_ = WebSurfaceRoute::kNone;
web_surface_logical_generation_ = 0;
web_surface_close_when_challenge_solved_ = false;
exit_observer = std::move(web_surface_logical_exit_observer_);
}
if (exit_observer.valid()) {
@@ -693,6 +767,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
// host back navigation too.
presentation.back_navigation_disabled =
request.back_button_visible.has_value() && !*request.back_button_visible;
presentation.close_when_challenge_solved =
request.close_when_challenge_solved;
return composition->OpenWebSurface(request.url, "webview",
WebSurfaceRoute::kWebView,
std::move(exit_observer), presentation);
@@ -951,9 +1027,45 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
route = web_surface_route_;
web_view_bridge = web_view_bridge_.get();
}
return event.type == WebViewHelperEventType::kRobloxCookie
? AcceptWebViewRobloxCookie(event.payload)
: RouteWebSurfaceEvent(route, event, web_view_bridge);
if (event.type == WebViewHelperEventType::kRobloxCookie) {
return AcceptWebViewRobloxCookie(event.payload);
}
if (event.type == WebViewHelperEventType::kExecuteRoblox) {
// A web Play button's launchGame is launched through the experience
// pipeline and closes the surface. It is never forwarded, so Roblox
// cannot start a second join from the same command.
RobloxExperienceLaunchRequest request;
if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() &&
Dispatch(request).ok()) {
std::fprintf(stderr,
"[experience] WebView launchGame queued place_id=%lld\n",
static_cast<long long>(request.place_id));
return CloseWebSurface();
}
}
const Status status = RouteWebSurfaceEvent(route, event, web_view_bridge);
if (!status.ok() || !IsRobloxWebViewChallengeSuccess(event.payload)) {
return status;
}
std::fprintf(stderr, " [webview] solved challenge forwarded to Roblox\n");
bool close_now = false;
{
std::lock_guard<std::mutex> lock(mutex_);
if (logical_generation != web_surface_logical_generation_) {
return status;
}
close_now = web_surface_close_when_challenge_solved_;
if (!close_now) {
solved_challenge_close_deadline_ =
std::chrono::steady_clock::now() + kSolvedChallengeCloseGrace;
solved_challenge_logical_generation_ = logical_generation;
}
}
if (!close_now) {
return status;
}
std::fprintf(stderr, " [webview] closing solved captcha window\n");
return CloseWebSurface(logical_generation);
}
Status RobloxExperienceComposition::DrainPlatformEvents() {
@@ -1002,6 +1114,9 @@ Status RobloxExperienceComposition::DrainPlatformEvents() {
status = drain_helper(web_surface_process, process_generation,
logical_generation);
}
if (status.ok()) {
status = CloseUnclaimedSolvedChallenge();
}
if (status.ok() && browser_service_bridge != nullptr) {
status = browser_service_bridge->DrainOutgoingEvents();
}
@@ -1543,6 +1658,7 @@ Status RobloxExperienceComposition::Shutdown() {
std::unique_ptr<RobloxWebViewBridge> web_view_bridge;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge;
std::unique_ptr<RobloxAccountBridge> account_bridge;
std::shared_ptr<WebViewHelperProcess> web_surface_process;
jnivm::VM* late_lifecycle_vm = nullptr;
{
@@ -1567,6 +1683,7 @@ Status RobloxExperienceComposition::Shutdown() {
web_view_bridge = std::move(web_view_bridge_);
browser_service_bridge = std::move(browser_service_bridge_);
permissions_bridge = std::move(permissions_bridge_);
account_bridge = std::move(account_bridge_);
web_surface_process = std::move(web_surface_process_);
web_surface_logical_exit_observer_ = {};
web_surface_route_ = WebSurfaceRoute::kNone;
@@ -1583,8 +1700,12 @@ Status RobloxExperienceComposition::Shutdown() {
if (web_surface_process != nullptr) {
(void)web_surface_process->RequestClose();
}
Status status = permissions_bridge != nullptr ? permissions_bridge->Shutdown()
: Status::Ok();
Status status =
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
const Status permissions_status = permissions_bridge != nullptr
? permissions_bridge->Shutdown()
: Status::Ok();
if (status.ok()) status = permissions_status;
const Status browser_status = browser_service_bridge != nullptr
? browser_service_bridge->Shutdown()
: Status::Ok();
@@ -1600,6 +1721,7 @@ Status RobloxExperienceComposition::Shutdown() {
status = bridge_status;
}
browser_service_bridge.reset();
account_bridge.reset();
permissions_bridge.reset();
web_view_bridge.reset();
bridge.reset();
+9 -8
View File
@@ -274,14 +274,15 @@ AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode,
return {};
}
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button) {
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button) {
switch (sdl_button) {
case SDL_BUTTON_LEFT:
return 0;
case SDL_BUTTON_RIGHT:
return 1;
case SDL_BUTTON_MIDDLE:
return 3;
// MouseButton3 is index 2, not BUTTON_TERTIARY (4) minus one.
return 2;
case SDL_BUTTON_X1:
return 7;
case SDL_BUTTON_X2:
@@ -586,11 +587,11 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
RobloxInputEventKind::kMouseButton,
Unsupported("native mouse buttons are unavailable"));
}
const int32_t android_button = MapSdlMouseButtonToAndroid(event.button);
if (android_button < 0) {
const int32_t roblox_button = MapSdlMouseButtonToRoblox(event.button);
if (roblox_button < 0) {
return Result(RobloxInputDispatchState::kIgnoredUnsupported,
RobloxInputEventKind::kMouseButton,
Unsupported("SDL mouse button has no Android mapping"));
Unsupported("SDL mouse button has no Roblox mapping"));
}
const platform::SurfaceCoordinateTransform transform =
CoordinateTransform(snapshot_.viewport);
@@ -616,12 +617,12 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
}
}
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
event.pressed, android_button);
event.pressed, roblox_button);
if (status.ok()) {
const auto active = std::find(active_mouse_buttons_.begin(),
active_mouse_buttons_.end(), android_button);
active_mouse_buttons_.end(), roblox_button);
if (event.pressed && active == active_mouse_buttons_.end()) {
active_mouse_buttons_.push_back(android_button);
active_mouse_buttons_.push_back(roblox_button);
} else if (!event.pressed && active != active_mouse_buttons_.end()) {
active_mouse_buttons_.erase(active);
}
+59
View File
@@ -640,5 +640,64 @@ Status ParseRobloxLaunchUri(std::string_view uri,
return MakeRequest(fields, request);
}
Status ParseRobloxWebViewLaunchGame(std::string_view command,
RobloxExperienceLaunchRequest* request) {
if (request == nullptr) {
return Invalid("Roblox launchGame output is null");
}
*request = {};
const nlohmann::json document =
command.size() <= kMaximumRobloxLaunchUriBytes
? nlohmann::json::parse(command, nullptr, false)
: nlohmann::json();
const auto function_name = document.is_object()
? document.find("functionName")
: document.end();
if (!document.is_object() || function_name == document.end() ||
*function_name != "launchGame") {
return Status::Error(StatusCode::kUnsupported,
"WebView command is not launchGame");
}
const auto params = document.find("params");
if (params == document.end() || !params->is_object()) {
return Invalid("Roblox launchGame has no params");
}
const auto launch = params->find("request");
if (launch == params->end() || !launch->is_object()) {
return Invalid("Roblox launchGame has no request");
}
if (launch->size() > kMaximumParameterCount) {
return Invalid("Roblox launchGame request has too many fields");
}
LaunchFields fields;
for (const auto& [key, value] : launch->items()) {
std::string text;
if (value.is_string()) {
text = value.get<std::string>();
} else if (value.is_number_unsigned()) {
text = std::to_string(value.get<uint64_t>());
} else {
continue;
}
if (text.size() > kMaximumParameterValueBytes ||
ContainsControlBytes(text)) {
return Invalid("Roblox launchGame field is invalid");
}
Status status = AssignField(key == "requestType" ? "request" : key,
std::move(text), false, &fields);
if (!status.ok()) {
return status;
}
}
if (fields.request_type.empty()) {
return Invalid("Roblox launchGame request type is missing");
}
Status status = MakeRequest(fields, request);
if (!status.ok()) {
*request = {};
}
return status;
}
} // namespace runtime
} // namespace mocktail
@@ -1,7 +1,11 @@
#include "runtime/roblox_platform_web_symbols.h"
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include "compat/web_view_protocol_contract.h"
#include "linker/linker.h"
namespace mocktail {
@@ -14,6 +18,41 @@ Function Resolve(void* library, const char* name) {
linker::ResolveSymbol(library, std::string(name)));
}
// Reads the mapped initializer and follows its getter call only when both
// match the code contract for this host's architecture. Both reads stay
// inside libroblox.so's text segment: the initializer is longer than the scan
// window, and the getter address comes from a verified call site.
AcquireWebViewProtocolFn FindWebViewProtocolGetter(const void* initializer) {
if (initializer == nullptr) return nullptr;
const auto address = reinterpret_cast<std::uintptr_t>(initializer);
const std::string_view code(static_cast<const char*>(initializer),
compat::kWebViewProtocolInitializerScanBytes);
#if defined(__x86_64__)
const std::optional<std::uintptr_t> getter =
compat::FindWebViewProtocolGetterCallX86_64(code, address);
const bool getter_matches =
getter.has_value() &&
compat::HasWebViewProtocolGetterContractX86_64(std::string_view(
reinterpret_cast<const char*>(*getter),
compat::kWebViewProtocolGetterBytesX86_64));
#elif defined(__aarch64__)
const std::optional<std::uintptr_t> getter =
compat::FindWebViewProtocolGetterCallArm64(code, address);
const bool getter_matches =
getter.has_value() &&
compat::HasWebViewProtocolGetterContractArm64(std::string_view(
reinterpret_cast<const char*>(*getter),
compat::kWebViewProtocolGetterBytesArm64));
#else
const std::optional<std::uintptr_t> getter;
const bool getter_matches = false;
(void)code;
(void)address;
#endif
return getter_matches ? reinterpret_cast<AcquireWebViewProtocolFn>(*getter)
: nullptr;
}
} // namespace
RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
@@ -46,6 +85,9 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
roblox_library,
"Java_com_roblox_protocols_webview_WebViewProtocol_"
"initializeAndroidWebViewProtocol");
symbols.web_view.acquire_web_view_protocol = FindWebViewProtocolGetter(
reinterpret_cast<const void*>(
symbols.web_view.initialize_android_web_view_protocol));
symbols.web_view.subscribe_raw = subscribe_raw;
symbols.web_view.delete_connection = delete_connection;
symbols.web_view.set_request_handler_raw =
@@ -118,6 +160,24 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
roblox_library,
"Java_com_roblox_universalapp_messagebus_MessageBus_"
"callResponseHandlerRaw");
using AccountStringFn = RobloxAccountProtocolSymbols::GetStringFn;
constexpr char kAccountPrefix[] =
"Java_com_roblox_universalapp_account_JNIAccountProtocol_";
const auto account_getter = [&](const char* name) {
return Resolve<AccountStringFn>(roblox_library,
(std::string(kAccountPrefix) + name).c_str());
};
symbols.account.get_protocol_name = account_getter("getProtocolName");
symbols.account.get_device_integrity_available_method_name =
account_getter("getDeviceIntegrityAvailableMethodName");
symbols.account.get_integrity_token_method_name =
account_getter("getGetIntegrityTokenMethodName");
symbols.account.get_support_key = account_getter("getSupportKey");
symbols.account.get_token_key = account_getter("getTokenKey");
symbols.account.get_result_key = account_getter("getResultKey");
symbols.account.set_request_handler_raw =
symbols.web_view.set_request_handler_raw;
symbols.account.clear_request_handler = symbols.web_view.clear_request_handler;
return symbols;
}
+49
View File
@@ -9,6 +9,7 @@
#include <utility>
#include "runtime/environment.h"
#include "webview/roblox_challenge_features.h"
namespace mocktail {
namespace runtime {
@@ -401,6 +402,39 @@ Status ParseRobloxCaptchaOpenJson(const std::string& json,
return Status::Ok();
}
bool IsRobloxWebViewChallengeSuccess(std::string_view command) {
if (command.empty() || command.size() > kMaximumRobloxWebViewJsonBytes) {
return false;
}
const nlohmann::json document =
nlohmann::json::parse(command.begin(), command.end(), nullptr, false);
const auto string_field = [](const nlohmann::json& object,
const char* key) -> const std::string* {
const auto field = object.find(key);
return field != object.end() && field->is_string()
? &field->get_ref<const std::string&>()
: nullptr;
};
const auto object_field = [](const nlohmann::json& object,
const char* key) -> const nlohmann::json* {
const auto field = object.find(key);
return field != object.end() && field->is_object() ? &*field : nullptr;
};
if (!document.is_object()) {
return false;
}
const std::string* module = string_field(document, "moduleID");
const std::string* function = string_field(document, "functionName");
const nlohmann::json* outer = object_field(document, "params");
const nlohmann::json* navigation =
outer != nullptr ? object_field(*outer, "params") : nullptr;
const std::string* feature =
navigation != nullptr ? string_field(*navigation, "feature") : nullptr;
return module != nullptr && *module == "Navigation" && function != nullptr &&
*function == "navigateToFeature" && feature != nullptr &&
webview::IsRobloxChallengeSolvedFeature(*feature);
}
Status ParseRobloxProfileViewUrl(const std::string& url,
RobloxWebViewOpenRequest* request) {
if (request == nullptr) {
@@ -911,6 +945,20 @@ Status RobloxWebViewBridge::Initialize() {
// handler and window subscriptions are installed before the native Android
// protocol is initialized. Calling the native initializer earlier loses
// messages that it can publish synchronously during startup.
// Roblox's Android app start holds a WebViewProtocol handle for the process
// lifetime. Without one, the protocol is deleted as soon as each caller
// releases it, and page callbacks wait for the next Lua WebView call to
// recreate it. Taking it before the native initializer keeps the protocol
// that initializer configures.
if (symbols_.acquire_web_view_protocol != nullptr) {
if (web_view_protocol_handle_.holder() == nullptr) {
web_view_protocol_handle_ = symbols_.acquire_web_view_protocol();
}
} else {
std::fprintf(stderr,
" [webview] WebViewProtocol getter not recognized; page "
"callbacks may wait for the next Roblox WebView call\n");
}
jclass initialization_class = env->FindClass(kWebViewProtocolClass);
if (initialization_class != nullptr) {
symbols_.initialize_android_web_view_protocol(env, initialization_class);
@@ -1266,6 +1314,7 @@ Status RobloxWebViewBridge::DispatchOpenRequest(
exit_context->target->browser_login_fallback = browser_login_fallback;
}
}
request.close_when_challenge_solved = source == OpenSource::kCaptcha;
if (status.ok()) {
status = sink_.dispatch_open(
sink_.context, request,
+7 -4
View File
@@ -14,6 +14,8 @@
#include <unistd.h>
#include <utility>
#include "compat/guest_abi.h"
namespace mocktail {
namespace runtime {
namespace {
@@ -438,8 +440,8 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
const std::filesystem::path library = canonical_root / "libroblox.so";
const std::filesystem::path package_root = canonical_root / "sober_apk";
const std::filesystem::path base_apk = package_root / "base.apk";
const std::filesystem::path x86_64_split_apk =
package_root / "split_config.x86_64.apk";
const std::filesystem::path arch_split_apk =
package_root / compat::kGuestSplitApkFile;
const std::filesystem::path assets_root = canonical_root / "assets";
const std::filesystem::path assets = canonical_root / "assets/content";
const std::filesystem::file_status library_status =
@@ -460,7 +462,8 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
for (const auto& [path, name] :
std::array<std::pair<std::filesystem::path, const char*>, 2>{
std::pair{base_apk, "base.apk"},
std::pair{x86_64_split_apk, "split_config.x86_64.apk"}}) {
std::pair{arch_split_apk,
compat::kGuestSplitApkFile.data()}}) {
const std::filesystem::file_status status =
std::filesystem::symlink_status(path, filesystem_error);
if (filesystem_error || !std::filesystem::is_regular_file(status) ||
@@ -489,7 +492,7 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
result.root = canonical_root;
result.roblox_library = library;
result.base_apk = base_apk;
result.x86_64_split_apk = x86_64_split_apk;
result.arch_split_apk = arch_split_apk;
result.assets_content = assets;
const auto compatibility_manifest =
+25 -13
View File
@@ -28,6 +28,7 @@
#include <vector>
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
#include "update/android_manifest.h"
#include "update/apk_signature.h"
#include "update/payload_integrity.h"
@@ -173,10 +174,15 @@ bool EndsWith(std::string_view value, std::string_view suffix) {
struct ApkCandidate {
std::filesystem::path path;
AndroidManifestIdentity identity;
bool has_x86_64_library = false;
bool has_guest_library = false;
bool has_assets = false;
};
std::string GuestLibEntryName() {
return std::string(compat::kGuestLibApkEntryPrefix) +
std::string(compat::kGuestAbi) + "/libroblox.so";
}
bool CollectCandidates(const std::vector<std::filesystem::path>& archives,
const std::filesystem::path& directory,
std::vector<std::filesystem::path>* candidates,
@@ -250,7 +256,7 @@ bool InspectCandidates(const std::vector<std::filesystem::path>& paths,
ApkCandidate candidate;
candidate.path = path;
candidate.identity = std::move(identity);
candidate.has_x86_64_library = HasEntry(list, "lib/x86_64/libroblox.so");
candidate.has_guest_library = HasEntry(list, GuestLibEntryName());
candidate.has_assets = std::any_of(
list.entries.begin(), list.entries.end(), [](const ZipEntry& entry) {
return entry.name.size() > 7 && entry.name.substr(0, 7) == "assets/";
@@ -314,7 +320,8 @@ std::vector<std::string> CommonTrustedCertificates(
const ApkSignatureResult split_signature =
base == split ? base_signature : VerifyApkSignature(split);
if (!split_signature) {
*error = "x86_64 APK signature: " + split_signature.error;
*error = std::string(compat::kGuestAbi) +
" APK signature: " + split_signature.error;
return {};
}
std::vector<std::string> accepted;
@@ -356,9 +363,11 @@ bool ValidateElf(const std::filesystem::path& path, std::string* build_id,
}
GElf_Ehdr header{};
if (gelf_getehdr(elf.get(), &header) == nullptr ||
header.e_machine != EM_X86_64 || gelf_getclass(elf.get()) != ELFCLASS64) {
header.e_machine != compat::kGuestElfMachine ||
gelf_getclass(elf.get()) != ELFCLASS64) {
close_elf();
*error = "libroblox.so is not an x86-64 ELF";
*error = "libroblox.so is not a " + std::string(compat::kGuestAbi) +
" ELF";
return false;
}
std::size_t section_count = 0;
@@ -464,16 +473,18 @@ PreparedPayload PreparePayloadFromArchives(
base = &candidate;
}
}
const std::string guest_split_name(compat::kGuestApkSplitName);
for (const ApkCandidate& candidate : candidates) {
if (candidate.has_x86_64_library &&
(candidate.identity.split_name == "config.x86_64" ||
if (candidate.has_guest_library &&
(candidate.identity.split_name == guest_split_name ||
candidate.identity.split_name.empty()) &&
split == nullptr) {
split = &candidate;
}
}
if (base == nullptr || split == nullptr) {
result.error = "no matching Roblox base and x86_64 APK pair was found";
result.error = "no matching Roblox base and " +
std::string(compat::kGuestAbi) + " APK pair was found";
return result;
}
const std::set<std::string> trusted =
@@ -488,12 +499,13 @@ PreparedPayload PreparePayloadFromArchives(
if (filesystem_error ||
!CopyRegular(base->path, prepared / "sober_apk/base.apk",
&result.error) ||
!CopyRegular(split->path, prepared / "sober_apk/split_config.x86_64.apk",
!CopyRegular(split->path,
prepared / "sober_apk" / compat::kGuestSplitApkFile,
&result.error)) {
if (result.error.empty()) result.error = "cannot create prepared payload";
return result;
}
if (!ExtractZipEntry(split->path, "lib/x86_64/libroblox.so",
if (!ExtractZipEntry(split->path, GuestLibEntryName(),
prepared / "libroblox.so", kMaximumApkBytes,
&result.error)) {
return result;
@@ -527,7 +539,7 @@ PreparedPayload PreparePayloadFromArchives(
const std::string base_hash =
HashRegularFile(prepared / "sober_apk/base.apk", &hash_error);
const std::string split_hash = HashRegularFile(
prepared / "sober_apk/split_config.x86_64.apk", &hash_error);
prepared / "sober_apk" / compat::kGuestSplitApkFile, &hash_error);
if (!hash_error.empty()) {
result.error = hash_error;
return result;
@@ -537,12 +549,12 @@ PreparedPayload PreparePayloadFromArchives(
{"package", "com.roblox.client"},
{"version_name", expected.version_name},
{"version_code", expected.version_code},
{"abi", "x86_64"},
{"abi", std::string(compat::kGuestAbi)},
{"elf_build_id", build_id},
{"sha256",
{{"libroblox", library_hash},
{"base_apk", base_hash},
{"x86_64_split_apk", split_hash}}},
{std::string(compat::kGuestSplitApkHashKey), split_hash}}},
{"signing_certificates_sha256", accepted},
{"assets",
{{"file_count", asset_count_verified},
+4 -2
View File
@@ -9,6 +9,7 @@
#include <string>
#include <utility>
#include "compat/guest_abi.h"
#include "update/http_download.h"
namespace mocktail::update {
@@ -199,7 +200,8 @@ std::vector<std::string> ParseApkPureExactDownloadUrls(
}
ProviderVersion ApkPureProvider::CheckLatest() const {
const HttpBytesResult metadata = FetchMetadata("x86_64");
const HttpBytesResult metadata =
FetchMetadata(std::string(compat::kGuestAbi));
if (!metadata) return ProviderVersion{{}, 0, metadata.error};
return ParseApkPureLatestMetadata(metadata.bytes);
}
@@ -223,7 +225,7 @@ ProviderDownloadResult ApkPureProvider::DownloadExact(
result.error = "APKPure output directory must exist and be empty";
return result;
}
HttpBytesResult metadata = FetchMetadata("x86_64");
HttpBytesResult metadata = FetchMetadata(std::string(compat::kGuestAbi));
if (!metadata) {
result.error = metadata.error;
return result;
+2 -1
View File
@@ -19,6 +19,7 @@
#include <string_view>
#include "compat/elf_build_id.h"
#include "compat/guest_abi.h"
#include "update/payload_integrity.h"
namespace mocktail::update {
@@ -260,7 +261,7 @@ std::string PayloadRuntimeFingerprint(
evidence.append("base_apk=")
.append(payload.metadata.base_apk_sha256)
.append("\n");
evidence.append("x86_64_split=")
evidence.append(std::string(compat::kGuestAbi) + "_split=")
.append(payload.metadata.split_apk_sha256)
.append("\n");
evidence.append("assets=")
+13
View File
@@ -11,6 +11,9 @@
#include <cerrno>
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
#include "compat/guest_abi.h"
namespace mocktail::update {
namespace {
@@ -81,6 +84,16 @@ CompatibilityCatalogResult LoadCompatibilityCatalog(
profile.value("allow_legacy_binary_patches", true)) {
continue;
}
const auto declared_abi = profile.find("abi");
if (declared_abi != profile.end() && !declared_abi->is_string()) {
result.error = "supported compatibility profile is incomplete";
return result;
}
const std::string_view profile_abi =
declared_abi == profile.end()
? std::string_view("x86_64")
: std::string_view(declared_abi->get_ref<const std::string&>());
if (profile_abi != compat::kGuestAbi) continue;
if (!profile.contains("version_name") ||
!profile["version_name"].is_string() ||
!profile.contains("version_code") ||
+5 -3
View File
@@ -30,6 +30,7 @@
#include "compat/elf_build_id.h"
#include "compat/fmod_output_device_contract.h"
#include "compat/guest_abi.h"
#include "update/payload_integrity.h"
namespace mocktail::update {
@@ -222,9 +223,10 @@ class ElfImage final {
if (elf_ == nullptr || elf_kind(elf_) != ELF_K_ELF ||
gelf_getehdr(elf_, &header) == nullptr ||
gelf_getclass(elf_) != ELFCLASS64 || header.e_type != ET_DYN ||
header.e_machine != EM_X86_64 || header.e_version != EV_CURRENT ||
header.e_machine != compat::kGuestElfMachine ||
header.e_version != EV_CURRENT ||
header.e_ident[EI_DATA] != ELFDATA2LSB) {
*error = "candidate must be a little-endian x86-64 ET_DYN ELF";
*error = "candidate must be a little-endian guest-abi ET_DYN ELF";
return false;
}
std::size_t program_count = 0;
@@ -1901,7 +1903,7 @@ std::optional<Json> LoadCandidateMetadata(const std::filesystem::path& root,
metadata->contains("sha256") ? &(*metadata)["sha256"] : nullptr;
if (metadata->value("schema_version", 0) != 1 ||
metadata->value("package", "") != "com.roblox.client" ||
metadata->value("abi", "") != "x86_64" || version_name.empty() ||
metadata->value("abi", "") != compat::kGuestAbi || version_name.empty() ||
version_code == 0 ||
metadata->value("elf_build_id", "") != candidate.build_id() ||
hashes == nullptr || !hashes->is_object() ||
+6 -3
View File
@@ -18,6 +18,8 @@
#include <thread>
#include <vector>
#include "compat/guest_abi.h"
namespace mocktail::update {
namespace {
@@ -291,7 +293,7 @@ PayloadIntegrityResult InspectPreparedPayload(
for (const std::filesystem::path& relative : {
std::filesystem::path("libroblox.so"),
std::filesystem::path("sober_apk/base.apk"),
std::filesystem::path("sober_apk/split_config.x86_64.apk"),
std::filesystem::path("sober_apk") / compat::kGuestSplitApkFile,
std::filesystem::path("roblox_payload.json"),
}) {
const auto status =
@@ -335,7 +337,7 @@ PayloadIntegrityResult InspectPreparedPayload(
result.metadata.library_sha256 = document["sha256"].value("libroblox", "");
result.metadata.base_apk_sha256 = document["sha256"].value("base_apk", "");
result.metadata.split_apk_sha256 =
document["sha256"].value("x86_64_split_apk", "");
document["sha256"].value(std::string(compat::kGuestSplitApkHashKey), "");
if (!document["assets"].contains("file_count") ||
!document["assets"]["file_count"].is_number_unsigned()) {
result.error = "payload metadata asset count is invalid";
@@ -368,7 +370,8 @@ PayloadIntegrityResult VerifyPreparedPayload(
const std::array<std::pair<std::filesystem::path, std::string>, 3> files = {{
{"libroblox.so", result.metadata.library_sha256},
{"sober_apk/base.apk", result.metadata.base_apk_sha256},
{"sober_apk/split_config.x86_64.apk", result.metadata.split_apk_sha256},
{std::filesystem::path("sober_apk") / compat::kGuestSplitApkFile,
result.metadata.split_apk_sha256},
}};
for (const auto& [relative, expected] : files) {
const std::string actual =
+3 -20
View File
@@ -108,15 +108,9 @@ struct SurfaceState {
unsigned int resource_retries = 0;
};
// A solved challenge stays open: the host forwards the event to Roblox, and
// Roblox or the host closes the window after Roblox has received it.
struct CallbackConfirmation {
CallbackConfirmation(GtkWindow* surface_window, CaptchaEventType event_type)
: type(event_type) {
g_weak_ref_init(&window, G_OBJECT(surface_window));
}
~CallbackConfirmation() { g_weak_ref_clear(&window); }
GWeakRef window;
CaptchaEventType type = CaptchaEventType::kShown;
};
@@ -612,16 +606,6 @@ void FinishCallbackConfirmation(GObject* source_object, GAsyncResult* result,
if (value != nullptr) {
g_object_unref(value);
}
auto* window_object =
static_cast<GObject*>(g_weak_ref_get(&confirmation->window));
if (confirmed && confirmation->type == CaptchaEventType::kSuccess &&
window_object != nullptr) {
gtk_window_close(GTK_WINDOW(window_object));
}
if (window_object != nullptr) {
g_object_unref(window_object);
}
delete confirmation;
}
@@ -644,9 +628,8 @@ void HandleHybridCommand(WebKitUserContentManager* manager,
if (window_object == nullptr) {
return;
}
auto* confirmation =
new CallbackConfirmation(GTK_WINDOW(window_object), event.type);
g_object_unref(window_object);
auto* confirmation = new CallbackConfirmation{event.type};
const std::string script = BuildCallbackScript(event.callback_id);
WebKitWebView* web_view = WEBKIT_WEB_VIEW(
g_object_get_data(G_OBJECT(manager), "mocktail-web-view"));
+3 -1
View File
@@ -9,6 +9,8 @@
#include <string>
#include <string_view>
#include "webview/roblox_challenge_features.h"
namespace mocktail {
namespace webview {
namespace {
@@ -306,7 +308,7 @@ bool ParseCaptchaEvent(std::string_view command, CaptchaEvent* event) {
event->type = CaptchaEventType::kShown;
return true;
}
if (feature == "CaptchaSuccess") {
if (IsRobloxChallengeSolvedFeature(feature)) {
event->type = CaptchaEventType::kSuccess;
return true;
}
+20
View File
@@ -370,6 +370,15 @@ target_link_libraries(roblox_permissions_bridge_test PRIVATE
)
mocktail_apply_compile_options(roblox_permissions_bridge_test)
add_executable(roblox_account_bridge_test roblox_account_bridge_test.cc)
target_link_libraries(roblox_account_bridge_test PRIVATE
Mocktail::LegacyJni
Mocktail::Runtime
GTest::gtest_main
Threads::Threads
)
mocktail_apply_compile_options(roblox_account_bridge_test)
add_executable(roblox_browser_service_bridge_test
roblox_browser_service_bridge_test.cc
)
@@ -713,6 +722,15 @@ target_link_libraries(elf_build_id_test PRIVATE
GTest::gtest_main
)
add_executable(web_view_protocol_contract_test
web_view_protocol_contract_test.cc
)
target_link_libraries(web_view_protocol_contract_test PRIVATE
Mocktail::Compat
GTest::gtest_main
)
mocktail_apply_compile_options(web_view_protocol_contract_test)
add_executable(build_profile_test build_profile_test.cc)
target_link_libraries(build_profile_test PRIVATE
Mocktail::Compat
@@ -1006,6 +1024,7 @@ gtest_discover_tests(roblox_game_session_native_adapter_test)
gtest_discover_tests(roblox_experience_launch_bridge_test)
gtest_discover_tests(roblox_web_view_bridge_test)
gtest_discover_tests(roblox_permissions_bridge_test)
gtest_discover_tests(roblox_account_bridge_test)
gtest_discover_tests(roblox_browser_service_bridge_test)
gtest_discover_tests(roblox_fresh_game_launch_controller_test)
gtest_discover_tests(roblox_experience_composition_test)
@@ -1039,6 +1058,7 @@ gtest_discover_tests(auth_service_test)
gtest_discover_tests(auth_runtime_composition_test)
gtest_discover_tests(client_settings_service_test)
gtest_discover_tests(elf_build_id_test)
gtest_discover_tests(web_view_protocol_contract_test)
gtest_discover_tests(build_profile_test)
gtest_discover_tests(host_abi_profile_test)
gtest_discover_tests(payload_compatibility_host_abi_gate_test)
+40 -3
View File
@@ -45,6 +45,8 @@ jq --arg hash "${UPDATED_FFMPEG_HASH}" '
mkdir -p "${TEMP_DIR}/project/scripts" "${TEMP_DIR}/project/packaging"
cp "${ROOT}/scripts/install_anylinux_dependencies.sh" "${TEMP_DIR}/project/scripts/"
cp "${TEMP_DIR}/manifest.txt" "${TEMP_DIR}/project/packaging/anylinux-dependencies.txt"
cp "${ROOT}/packaging/anylinux-dependencies-aarch64.txt" \
"${TEMP_DIR}/project/packaging/"
# Record requests without any network access or changes to host packages.
curl() {
@@ -121,13 +123,13 @@ ExpectRejectedSnapshot() {
local description="$1" filter="$2"
jq "${filter}" "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
"${TEMP_DIR}/invalid.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
"${TEMP_DIR}/invalid.json" x86_64) >"${TEMP_DIR}/invalid.log" 2>&1; then
Fail "accepted ${description}"
fi
}
(AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/snapshot.tsv" ||
"${TEMP_DIR}/release.json" x86_64) >"${TEMP_DIR}/snapshot.tsv" ||
Fail 'rejected valid dependency snapshot'
printf '%s\t%s\t%s\n' \
"${FFMPEG_HASH}" "${FFMPEG}" "${API}/assets/10" \
@@ -136,6 +138,40 @@ printf '%s\t%s\t%s\n' \
cmp "${TEMP_DIR}/expected-snapshot.tsv" "${TEMP_DIR}/snapshot.tsv" ||
Fail 'incorrect dependency snapshot'
readonly ARM_PACKAGE=gdk-pixbuf2-mini-aarch64.pkg.tar.xz
printf '%s\n' "${ARM_PACKAGE}" >"${TEMP_DIR}/arm-manifest.txt"
jq --null-input --arg api "${API}" --arg name "${ARM_PACKAGE}" \
--arg hash "${FFMPEG_HASH}" '
{tag_name: "continuous", assets: [
{id: 40, name: $name, state: "uploaded",
digest: ("sha256:" + $hash), url: ($api + "/assets/40")}
]}
' >"${TEMP_DIR}/arm-release.json"
AnyLinuxDependencySnapshot "${TEMP_DIR}/arm-manifest.txt" \
"${TEMP_DIR}/arm-release.json" aarch64 >"${TEMP_DIR}/arm-snapshot.tsv" ||
Fail 'rejected valid aarch64 dependency snapshot'
printf '%s\t%s\t%s\n' "${FFMPEG_HASH}" "${ARM_PACKAGE}" \
"${API}/assets/40" >"${TEMP_DIR}/expected-arm-snapshot.tsv"
cmp "${TEMP_DIR}/expected-arm-snapshot.tsv" "${TEMP_DIR}/arm-snapshot.tsv" ||
Fail 'incorrect aarch64 dependency snapshot'
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/arm-manifest.txt" \
"${TEMP_DIR}/arm-release.json" x86_64) >/dev/null 2>&1; then
Fail 'accepted an aarch64 dependency on an x86_64 host'
fi
bash -Eeuo pipefail -c '
source "$1/project/scripts/install_anylinux_dependencies.sh"
AnyLinuxDependencyRequireContainer() { :; }
uname() { printf "aarch64\n"; }
AnyLinuxDownloadDependencies() {
printf "%s\n" "$1" >"${TEMP_DIR}/arm-selected-manifest"
exit 0
}
AnyLinuxDependenciesMain
' _ "${TEMP_DIR}" || Fail 'aarch64 manifest selection failed'
grep -Fxq "${TEMP_DIR}/project/packaging/anylinux-dependencies-aarch64.txt" \
"${TEMP_DIR}/arm-selected-manifest" ||
Fail 'aarch64 build did not select its dependency manifest'
ExpectRejectedSnapshot 'missing asset' '.assets |= .[1:]'
ExpectRejectedSnapshot 'duplicate asset' '.assets += [.assets[0]]'
ExpectRejectedSnapshot 'absent SHA256' '.assets[0].digest = null'
@@ -149,7 +185,7 @@ for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
"${FFMPEG} extra" "${FFMPEG}"$'\n'"${FFMPEG}" ''; do
printf '%s\n' "${invalid_manifest}" >"${TEMP_DIR}/invalid-manifest.txt"
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/invalid-manifest.txt" \
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
"${TEMP_DIR}/release.json" x86_64) >"${TEMP_DIR}/invalid.log" 2>&1; then
Fail 'accepted unsafe, duplicate, or empty package allowlist'
fi
done
@@ -166,6 +202,7 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
if GH_TOKEN=fixture-token bash -Eeuo pipefail -c '
source "$1/project/scripts/install_anylinux_dependencies.sh"
AnyLinuxDependencyRequireContainer() { :; }
uname() { printf "x86_64\n"; }
AnyLinuxDependenciesMain
' _ "${TEMP_DIR}" \
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
+6 -2
View File
@@ -1,5 +1,7 @@
#include "compat/bionic_abi_exports.h"
#include "compat/guest_abi.h"
#include <gtest/gtest.h>
#include <fcntl.h>
@@ -37,8 +39,10 @@ TEST(BionicAbiExportsTest, ReportsStableAndroidSystemProperties) {
EXPECT_STREQ("35", value.data());
value.fill('\0');
EXPECT_EQ(6, __system_property_get("ro.product.cpu.abi", value.data()));
EXPECT_STREQ("x86_64", value.data());
EXPECT_EQ(static_cast<int>(mocktail::compat::kGuestAbi.size()),
__system_property_get("ro.product.cpu.abi", value.data()));
EXPECT_STREQ(std::string(mocktail::compat::kGuestAbi).c_str(),
value.data());
value.fill('x');
EXPECT_EQ(0, __system_property_get("mocktail.unknown", value.data()));
+18 -14
View File
@@ -5,6 +5,8 @@
#include <sched.h>
#include <unistd.h>
#include "compat/bionic_abi_exports.h"
#include <algorithm>
#include <atomic>
@@ -189,9 +191,10 @@ TEST(BionicPthreadCreateRuntimeTest, GrowsRejectedExplicitGuestStack) {
0);
ASSERT_EQ(pthread_attr_destroy(&host_defaults), 0);
pthread_attr_t guest_attributes;
ASSERT_EQ(pthread_attr_init(&guest_attributes), 0);
ASSERT_EQ(pthread_attr_setstacksize(&guest_attributes, kRequestedStackSize),
MocktailBionicPthreadAttr guest_attributes;
ASSERT_EQ(mocktail_pthread_attr_init(&guest_attributes), 0);
ASSERT_EQ(mocktail_pthread_attr_setstacksize(&guest_attributes,
kRequestedStackSize),
0);
g_intercepted_create_calls.store(0, std::memory_order_release);
@@ -206,7 +209,7 @@ TEST(BionicPthreadCreateRuntimeTest, GrowsRejectedExplicitGuestStack) {
&thread, &guest_attributes, RecordThreadStackSize, nullptr);
g_intercept_pthread_create.store(false, std::memory_order_release);
ASSERT_EQ(pthread_attr_destroy(&guest_attributes), 0);
ASSERT_EQ(mocktail_pthread_attr_destroy(&guest_attributes), 0);
ASSERT_EQ(result, 0);
ASSERT_EQ(pthread_join(thread, nullptr), 0);
EXPECT_EQ(g_intercepted_create_calls.load(std::memory_order_acquire), 3);
@@ -223,15 +226,16 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
constexpr size_t kRequestedStackSize = 2 * 1024 * 1024;
g_observed_stack_size.store(0, std::memory_order_release);
pthread_attr_t attributes;
ASSERT_EQ(pthread_attr_init(&attributes), 0);
ASSERT_EQ(pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
MocktailBionicPthreadAttr attributes;
ASSERT_EQ(mocktail_pthread_attr_init(&attributes), 0);
ASSERT_EQ(
mocktail_pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
pthread_t thread{};
ASSERT_EQ(mocktail_bionic_pthread_create(&thread, &attributes,
RecordThreadStackSize, nullptr),
0);
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
EXPECT_EQ(mocktail_pthread_attr_destroy(&attributes), 0);
ASSERT_EQ(pthread_join(thread, nullptr), 0);
ExpectRequestedStackSize(
g_observed_stack_size.load(std::memory_order_acquire),
@@ -240,13 +244,13 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
for (const int policy : {SCHED_FIFO, SCHED_RR}) {
pthread_attr_t attributes;
ASSERT_EQ(pthread_attr_init(&attributes), 0);
ASSERT_EQ(pthread_attr_setinheritsched(&attributes, PTHREAD_EXPLICIT_SCHED), 0);
ASSERT_EQ(pthread_attr_setschedpolicy(&attributes, policy), 0);
MocktailBionicPthreadAttr attributes;
ASSERT_EQ(mocktail_pthread_attr_init(&attributes), 0);
attributes.sched_policy = policy;
sched_param parameters{};
parameters.sched_priority = 99;
ASSERT_EQ(pthread_attr_setschedparam(&attributes, &parameters), 0);
ASSERT_EQ(
mocktail_pthread_attr_setschedparam(&attributes, &parameters), 0);
g_intercepted_create_calls.store(0);
g_intercept_pthread_create.store(true);
pthread_t thread{};
@@ -255,7 +259,7 @@ TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
g_intercept_pthread_create.store(false);
EXPECT_EQ(result, EPERM);
EXPECT_EQ(g_intercepted_create_calls.load(), 0);
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
EXPECT_EQ(mocktail_pthread_attr_destroy(&attributes), 0);
}
}
+63
View File
@@ -312,6 +312,69 @@ TEST(ChromeTraceWriterTest, ActiveProfileTraceIsOffWithoutEnvironment) {
EXPECT_EQ(ActiveProfileTrace(), nullptr);
}
TEST(ChromeTraceWriterTest, CpuClocksAdvanceWithWork) {
// The process window encloses the thread window.
const std::uint64_t process_before = TraceProcessCpuNanos();
const std::uint64_t thread_before = TraceThreadCpuNanos();
volatile std::uint64_t sink = 0;
const auto until =
std::chrono::steady_clock::now() + std::chrono::milliseconds(20);
while (std::chrono::steady_clock::now() < until) {
sink = sink + 1;
}
const std::uint64_t thread_used = TraceThreadCpuNanos() - thread_before;
const std::uint64_t process_used = TraceProcessCpuNanos() - process_before;
EXPECT_GE(thread_used, 10'000'000U);
EXPECT_GE(process_used, thread_used);
}
TEST(FrameCpuSamplerTest, FirstPresentHasNoFrame) {
FrameCpuSampler sampler;
FrameCpuSample sample;
EXPECT_FALSE(sampler.Sample(1, 10'000'000, 50'000'000, &sample));
}
TEST(FrameCpuSamplerTest, MeasuresCpuSpentSinceThePreviousPresent) {
FrameCpuSampler sampler;
FrameCpuSample sample;
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
ASSERT_TRUE(sampler.Sample(1, 18'000'000, 90'000'000, &sample));
EXPECT_TRUE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.thread_ms, 8.0);
EXPECT_DOUBLE_EQ(sample.process_ms, 40.0);
}
TEST(FrameCpuSamplerTest, SkipsThreadCpuWhenAnotherThreadPresentedLast) {
FrameCpuSampler sampler;
FrameCpuSample sample;
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
ASSERT_TRUE(sampler.Sample(2, 3'000'000, 70'000'000, &sample));
EXPECT_FALSE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.process_ms, 20.0);
// Thread 2 presented last, so its next frame measures from here.
ASSERT_TRUE(sampler.Sample(2, 7'000'000, 80'000'000, &sample));
EXPECT_TRUE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.thread_ms, 4.0);
}
TEST(FrameCpuSamplerTest, ClocksReadOutOfOrderDoNotWrap) {
// Two presenting threads may read the process clock in one order and take
// the lock in the other; a reused thread id restarts its thread clock.
FrameCpuSampler sampler;
FrameCpuSample sample;
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
ASSERT_TRUE(sampler.Sample(1, 4'000'000, 45'000'000, &sample));
EXPECT_DOUBLE_EQ(sample.process_ms, 0.0);
EXPECT_FALSE(sample.has_thread);
// The later process reading stays the baseline.
ASSERT_TRUE(sampler.Sample(1, 6'000'000, 60'000'000, &sample));
EXPECT_DOUBLE_EQ(sample.process_ms, 10.0);
EXPECT_TRUE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.thread_ms, 2.0);
}
} // namespace
} // namespace graphics
} // namespace mocktail
+6 -2
View File
@@ -2,6 +2,8 @@
#include <gtest/gtest.h>
#include "compat/guest_abi.h"
#include <algorithm>
#include <array>
#include <cstdlib>
@@ -312,8 +314,10 @@ TEST(CommandLineTest, UsageContainsEverySupportedOption) {
EXPECT_EQ(usage.find("--login"), std::string::npos);
EXPECT_EQ(usage.find("--import-cookie"), std::string::npos);
EXPECT_NE(usage.find("native sign-in flow"), std::string::npos);
EXPECT_NE(usage.find("verified managed x86_64 Roblox payload"),
std::string::npos);
EXPECT_NE(
usage.find("verified managed " +
std::string(mocktail::compat::kGuestAbi) + " Roblox payload"),
std::string::npos);
EXPECT_EQ(usage.find("Normal startup uses rbx_bin/libroblox.so"),
std::string::npos);
EXPECT_EQ(usage.find("--launch-request-json"), std::string::npos);
+3 -1
View File
@@ -176,8 +176,10 @@ grep -Fq './scripts/build_flatpak.sh' <<<"${dry_run}" ||
Fail 'make flatpak does not invoke the guarded Flatpak builder'
grep -Fq -- '--manifest packaging/flatpak/io.github.CoderDayton.nightcap.json' \
<<<"${dry_run}" || Fail 'make flatpak lost the canonical manifest'
grep -Fq -- '--jobs 4' <<<"${dry_run}" ||
grep -Fq -- '--jobs 2' <<<"${dry_run}" ||
Fail 'make flatpak does not cap local build parallelism'
grep -Fq 'JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"' "${BUILD_HELPER}" ||
Fail 'Flatpak helper does not cap local build parallelism'
mkdir -p -- "${TEMP_DIR}/bin"
cat >"${TEMP_DIR}/bin/uname" <<'EOF'
+37 -19
View File
@@ -13,6 +13,7 @@
#include <vector>
#include "compat/build_profile.h"
#include "compat/guest_abi.h"
#include "compat/host_abi_profile.h"
#include "update/apkpure_provider.h"
#include "update/compatibility_catalog.h"
@@ -521,6 +522,18 @@ TEST(ShippedMetadataTest, ReferenceSidecarDescribesASupportedProfile) {
const HostAbiSidecarIdentity identity = ReadHostAbiSidecarIdentity(
root / "config/roblox_host_abi_reference.json");
ASSERT_TRUE(identity) << identity.error;
std::string sidecar_abi = "x86_64";
for (const auto& entry :
nlohmann::json::parse(
ReadFile(root / "config/roblox_compatibility.json"))
.at("profiles")) {
if (entry.value("elf_build_id", "") == identity.elf_build_id) {
sidecar_abi = entry.value("abi", "x86_64");
}
}
if (sidecar_abi != compat::kGuestAbi) {
GTEST_SKIP() << "shipped reference sidecar targets " << sidecar_abi;
}
const auto supported = std::find_if(
catalog.profiles.begin(), catalog.profiles.end(),
@@ -561,6 +574,11 @@ TEST(ShippedMetadataTest, DefaultPayloadHasABuiltinProfileAndMatchingReference)
const auto payload = nlohmann::json::parse(
ReadFile(root / "config/roblox_payload.json"));
if (payload.at("abi").get_ref<const std::string&>() != compat::kGuestAbi) {
EXPECT_NE(compat::FindHostAbiProfile(preferred->elf_build_id), nullptr);
GTEST_SKIP() << "shipped default payload targets "
<< payload.at("abi").get<std::string>();
}
EXPECT_EQ(payload.at("version_name"), preferred->version_name);
EXPECT_EQ(payload.at("version_code"), preferred->version_code);
EXPECT_EQ(payload.at("elf_build_id"), preferred->elf_build_id);
@@ -646,7 +664,7 @@ TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
const std::filesystem::path restaged = temporary.root() / "restaged";
Write(prepared / "libroblox.so", "native-library");
Write(prepared / "sober_apk/base.apk", "base-apk");
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
Write(prepared / "assets/content/fixture", "asset");
std::string error;
std::size_t asset_count = 0;
@@ -662,8 +680,8 @@ TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
{"sha256",
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
{"x86_64_split_apk",
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
{std::string(compat::kGuestSplitApkHashKey),
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
{"source", "apk-pure-native"},
{"imported_at", "2026-09-04T03:49:16Z"},
@@ -707,7 +725,7 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
const std::filesystem::path prepared = temporary.root() / "prepared";
Write(prepared / "libroblox.so", "native-library");
Write(prepared / "sober_apk/base.apk", "base-apk");
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
Write(prepared / "assets/content/fixture", "asset");
std::string error;
std::size_t asset_count = 0;
@@ -725,8 +743,8 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
{"sha256",
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
{"x86_64_split_apk",
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
{std::string(compat::kGuestSplitApkHashKey),
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
};
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
@@ -770,7 +788,7 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
const std::filesystem::path prepared = temporary.root() / "prepared";
Write(prepared / "libroblox.so", "native-library");
Write(prepared / "sober_apk/base.apk", "base-apk");
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
Write(prepared / "assets/content/fixture", "asset");
std::string error;
std::size_t asset_count = 0;
@@ -788,8 +806,8 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
{"sha256",
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
{"x86_64_split_apk",
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
{std::string(compat::kGuestSplitApkHashKey),
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
};
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
@@ -840,7 +858,7 @@ TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
const std::filesystem::path prepared = temporary.root() / "prepared";
Write(prepared / "libroblox.so", "native-library");
Write(prepared / "sober_apk/base.apk", "base-apk");
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
Write(prepared / "assets/content/fixture", "asset");
std::string error;
std::size_t asset_count = 0;
@@ -856,8 +874,8 @@ TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
{"sha256",
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
{"x86_64_split_apk",
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
{std::string(compat::kGuestSplitApkHashKey),
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
};
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
@@ -900,7 +918,7 @@ TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
const std::filesystem::path prepared = temporary.root() / "prepared";
Write(prepared / "libroblox.so", "native-library");
Write(prepared / "sober_apk/base.apk", "base-apk");
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
Write(prepared / "assets/content/fixture", "asset");
std::string error;
std::size_t asset_count = 0;
@@ -916,8 +934,8 @@ TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
{"sha256",
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
{"x86_64_split_apk",
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
{std::string(compat::kGuestSplitApkHashKey),
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
};
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
@@ -961,7 +979,7 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
const std::filesystem::path prepared = temporary.root() / "prepared";
Write(prepared / "libroblox.so", "new-native-library");
Write(prepared / "sober_apk/base.apk", "new-base-apk");
Write(prepared / "sober_apk/split_config.x86_64.apk", "new-split-apk");
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "new-split-apk");
Write(prepared / "assets/content/fixture", "new-asset");
std::string error;
std::size_t asset_count = 0;
@@ -975,13 +993,13 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
{"package", "com.roblox.client"},
{"version_name", "2.732.1043"},
{"version_code", 2814},
{"abi", "x86_64"},
{"abi", std::string(compat::kGuestAbi)},
{"elf_build_id", kBuildId},
{"sha256",
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
{"x86_64_split_apk",
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
{std::string(compat::kGuestSplitApkHashKey),
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
};
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
@@ -164,6 +164,28 @@ TEST(SdlEventConverterTest, ConvertsFocusWithoutOwningTheSdlQueue) {
EXPECT_TRUE(focus->focused);
}
TEST(SdlEventConverterTest, PreservesHostWheelDirectionAndPrecision) {
SDL_Event source{};
source.type = SDL_EVENT_MOUSE_WHEEL;
source.wheel.mouse_x = 120.0f;
source.wheel.mouse_y = 240.0f;
platform::PlatformEvent event;
auto* non_owning_window = reinterpret_cast<SDL_Window*>(0x1);
for (const auto direction : {SDL_MOUSEWHEEL_NORMAL, SDL_MOUSEWHEEL_FLIPPED}) {
source.wheel.direction = direction;
source.wheel.x = 0.25f;
source.wheel.y = -1.5f;
ASSERT_TRUE(platform::ConvertSdlEvent(non_owning_window, source, &event));
const auto* wheel = std::get_if<platform::MouseWheelEvent>(&event.payload);
ASSERT_NE(wheel, nullptr);
EXPECT_FLOAT_EQ(wheel->delta_x, 0.25f);
EXPECT_FLOAT_EQ(wheel->delta_y, -1.5f);
EXPECT_FLOAT_EQ(wheel->mouse_x, 120.0f);
EXPECT_FLOAT_EQ(wheel->mouse_y, 240.0f);
}
}
TEST(SdlEventConverterTest, RejectsInvalidArgumentsAndUnsupportedEvents) {
SDL_Event source{};
source.type = SDL_EVENT_USER;
+221
View File
@@ -0,0 +1,221 @@
#include "runtime/roblox_account_bridge.h"
#include <gtest/gtest.h>
#include <algorithm>
#include <map>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
#include <vector>
#include "jnivm/jnivm.h"
namespace mocktail {
namespace runtime {
namespace {
using Json = nlohmann::json;
// Distinct from the production values so tests prove the bridge uses the
// names the engine reports instead of hard-coded strings.
constexpr char kProtocol[] = "TestAccountProtocol";
constexpr char kAvailableMethod[] = "TestIntegrityAvailable";
constexpr char kTokenMethod[] = "TestIntegrityToken";
constexpr char kSupportKey[] = "testSupport";
constexpr char kTokenKey[] = "testToken";
constexpr char kResultKey[] = "testResult";
constexpr char kTokenRequest[] =
R"({"requestHash":"opaque-hash","timeoutMillis":60000})";
std::string Copy(JNIEnv* env, jstring value) {
if (!value) return {};
const char* chars = env->GetStringUTFChars(value, nullptr);
const std::string copy = chars ? chars : "";
if (chars) env->ReleaseStringUTFChars(value, chars);
return copy;
}
struct Probe {
jnivm::VM* vm = nullptr;
std::map<std::string, jobject> handlers;
std::vector<std::string> native_cleared;
int handlers_created = 0;
int handlers_cleared = 0;
int fail_handler_at = 0;
bool null_token_key = false;
};
Probe* g_probe = nullptr;
void Prepare(void* context) {
static_cast<jnivm::VM*>(context)->RestoreFunctions();
}
jstring ProtocolName(JNIEnv* env, jclass) { return env->NewStringUTF(kProtocol); }
jstring AvailableMethod(JNIEnv* env, jclass) {
return env->NewStringUTF(kAvailableMethod);
}
jstring TokenMethod(JNIEnv* env, jclass) {
return env->NewStringUTF(kTokenMethod);
}
jstring SupportKey(JNIEnv* env, jclass) {
return env->NewStringUTF(kSupportKey);
}
jstring TokenKey(JNIEnv* env, jclass) {
return g_probe->null_token_key ? nullptr : env->NewStringUTF(kTokenKey);
}
jstring ResultKey(JNIEnv* env, jclass) { return env->NewStringUTF(kResultKey); }
void SetHandler(JNIEnv* env, jobject, jstring protocol, jstring method,
jobject handler) {
EXPECT_EQ(Copy(env, protocol), kProtocol);
g_probe->handlers[Copy(env, method)] = handler;
}
void ClearHandler(JNIEnv* env, jobject, jstring protocol, jstring method) {
EXPECT_EQ(Copy(env, protocol), kProtocol);
g_probe->native_cleared.push_back(Copy(env, method));
}
jobject CreateHandler(void* context, std::shared_ptr<void> target,
std::string (*run)(void*, JNIEnv*, jstring)) {
auto* probe = static_cast<Probe*>(context);
if (++probe->handlers_created == probe->fail_handler_at) return nullptr;
return probe->vm->CreateMessageBusRequestHandler(
std::move(target), jnivm::MessageBusRequestHandlerCallbacks{run});
}
void ClearHandlerObject(void* context, jobject handler) {
auto* probe = static_cast<Probe*>(context);
++probe->handlers_cleared;
probe->vm->ClearMessageBusRequestHandler(handler);
}
RobloxAccountProtocolSymbols Symbols() {
return {ProtocolName, AvailableMethod, TokenMethod, SupportKey,
TokenKey, ResultKey, SetHandler, ClearHandler};
}
class RobloxAccountBridgeTest : public testing::Test {
protected:
void SetUp() override {
probe.vm = &vm;
g_probe = &probe;
env = vm.GetJNIEnv();
jclass cls =
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
bus = env->AllocObject(cls);
env->DeleteLocalRef(cls);
}
void TearDown() override {
bridge.reset();
env->DeleteLocalRef(bus);
g_probe = nullptr;
}
Status Initialize(RobloxAccountProtocolSymbols symbols = Symbols()) {
bridge = std::make_unique<RobloxAccountBridge>(
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare}, symbols,
RobloxAccountProtocolObjects{bus, &probe, CreateHandler,
ClearHandlerObject});
return bridge->Initialize();
}
Json Request(const char* method, const char* payload) {
const auto found = probe.handlers.find(method);
EXPECT_NE(found, probe.handlers.end()) << method;
if (found == probe.handlers.end()) return nullptr;
jstring message = env->NewStringUTF(payload);
jstring response =
vm.DispatchMessageBusRequestHandler(found->second, env, message);
env->DeleteLocalRef(message);
const std::string body = Copy(env, response);
if (response) env->DeleteLocalRef(response);
return Json::parse(body, nullptr, false);
}
jnivm::VM vm;
Probe probe;
JNIEnv* env = nullptr;
jobject bus = nullptr;
std::unique_ptr<RobloxAccountBridge> bridge;
};
TEST_F(RobloxAccountBridgeTest, RegistersBothHandlersUnderEngineNames) {
ASSERT_TRUE(Initialize().ok());
EXPECT_EQ(probe.handlers.size(), 2u);
EXPECT_TRUE(probe.handlers.count(kAvailableMethod));
EXPECT_TRUE(probe.handlers.count(kTokenMethod));
}
TEST_F(RobloxAccountBridgeTest, ReportsDeviceIntegrityUnsupported) {
ASSERT_TRUE(Initialize().ok());
EXPECT_EQ(Request(kAvailableMethod, "{}"), (Json{{kSupportKey, false}}));
}
TEST_F(RobloxAccountBridgeTest, AnswersTokenRequestWithoutToken) {
ASSERT_TRUE(Initialize().ok());
EXPECT_EQ(Request(kTokenMethod, kTokenRequest),
(Json{{kTokenKey, ""}, {kResultKey, "TOKEN_PROVIDER_UNINITIALIZED"}}));
}
TEST_F(RobloxAccountBridgeTest, AnswersMalformedTokenRequestWithoutToken) {
ASSERT_TRUE(Initialize().ok());
EXPECT_EQ(Request(kTokenMethod, "not json"),
(Json{{kTokenKey, ""}, {kResultKey, "TOKEN_PROVIDER_UNINITIALIZED"}}));
}
TEST_F(RobloxAccountBridgeTest, RejectsIncompletePrerequisites) {
RobloxAccountProtocolSymbols symbols = Symbols();
symbols.get_token_key = nullptr;
EXPECT_FALSE(Initialize(symbols).ok());
EXPECT_TRUE(probe.handlers.empty());
EXPECT_EQ(probe.handlers_created, 0);
}
TEST_F(RobloxAccountBridgeTest, FailsWithoutRegisteringWhenEngineNameIsNull) {
probe.null_token_key = true;
EXPECT_FALSE(Initialize().ok());
EXPECT_TRUE(probe.handlers.empty());
EXPECT_EQ(probe.handlers_created, 0);
}
TEST_F(RobloxAccountBridgeTest, HandlerCreationFailureReleasesEarlierHandler) {
probe.fail_handler_at = 2;
EXPECT_FALSE(Initialize().ok());
EXPECT_EQ(probe.handlers_cleared, 1);
for (const auto& [method, handler] : probe.handlers) {
EXPECT_NE(std::find(probe.native_cleared.begin(),
probe.native_cleared.end(), method),
probe.native_cleared.end())
<< method;
}
}
TEST_F(RobloxAccountBridgeTest, InitializeTwiceFails) {
ASSERT_TRUE(Initialize().ok());
EXPECT_FALSE(bridge->Initialize().ok());
EXPECT_EQ(probe.handlers_created, 2);
}
TEST_F(RobloxAccountBridgeTest, ShutdownClearsBothHandlersOnce) {
ASSERT_TRUE(Initialize().ok());
EXPECT_TRUE(bridge->Shutdown().ok());
EXPECT_TRUE(bridge->Shutdown().ok());
EXPECT_EQ(probe.handlers_cleared, 2);
EXPECT_EQ(probe.native_cleared,
(std::vector<std::string>{kAvailableMethod, kTokenMethod}));
}
} // namespace
} // namespace runtime
} // namespace mocktail
+307 -1
View File
@@ -50,6 +50,8 @@ struct Probe {
int permission_disconnects = 0;
int permission_handlers_cleared = 0;
std::map<std::string, jobject> permission_handlers;
std::map<std::string, jobject> account_handlers;
int account_handlers_cleared = 0;
std::string permission_response;
int browser_bindings = 0;
int browser_disconnects = 0;
@@ -266,6 +268,45 @@ nlohmann::json QueryMicrophone(Probe* probe) {
}
void SetRequestHandler(JNIEnv*, jobject, jstring, jstring, jobject) {}
std::string CopyJavaString(JNIEnv* env, jstring value) {
const char* chars = value ? env->GetStringUTFChars(value, nullptr) : nullptr;
const std::string copy = chars ? chars : "";
if (chars) env->ReleaseStringUTFChars(value, chars);
return copy;
}
jstring AccountProtocolName(JNIEnv* env, jclass) {
return env->NewStringUTF("AccountProtocol");
}
jstring AccountAvailableMethod(JNIEnv* env, jclass) {
return env->NewStringUTF("DeviceIntegrityAvailable");
}
jstring AccountTokenMethod(JNIEnv* env, jclass) {
return env->NewStringUTF("GetIntegrityToken");
}
jstring AccountSupportKey(JNIEnv* env, jclass) {
return env->NewStringUTF("support");
}
jstring AccountTokenKey(JNIEnv* env, jclass) {
return env->NewStringUTF("token");
}
jstring AccountResultKey(JNIEnv* env, jclass) {
return env->NewStringUTF("result");
}
void SetAccountHandler(JNIEnv* env, jobject, jstring, jstring method,
jobject handler) {
g_probe->account_handlers[CopyJavaString(env, method)] = handler;
}
void ClearAccountHandler(JNIEnv*, jobject, jstring, jstring) {
++g_probe->account_handlers_cleared;
}
RobloxAccountProtocolSymbols AccountSymbols() {
return {AccountProtocolName, AccountAvailableMethod, AccountTokenMethod,
AccountSupportKey, AccountTokenKey, AccountResultKey,
SetAccountHandler, ClearAccountHandler};
}
void ClearNativeRequestHandler(JNIEnv*, jobject, jstring, jstring) {}
void PublishRaw(JNIEnv*, jobject, jstring, jstring) {}
void BroadcastDataModelFocus(JNIEnv*, jclass, jstring, jstring, jstring) {}
@@ -911,6 +952,49 @@ TEST(RobloxExperienceCompositionTest,
EXPECT_EQ(probe.browser_disconnects, 4);
EXPECT_EQ(probe.browser_releases, 4);
EXPECT_EQ(probe.browser_callbacks_cleared, 4);
EXPECT_TRUE(probe.account_handlers.empty());
g_probe = nullptr;
}
TEST(RobloxExperienceCompositionTest,
AnswersDeviceIntegrityAsUnsupportedWithPlatformProtocols) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
for (const char* class_name : {
"com/roblox/protocols/webview/WebViewProtocol",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm.RegisterClass(class_name);
}
RobloxExperienceComposition composition(
{vm.GetJavaVM(), &vm, Prepare}, {},
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(), {}, JniFactory(&probe),
{}, {}, nullptr, {}, {}, false, false, AccountSymbols());
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
ASSERT_EQ(probe.account_handlers.size(), 2u);
JNIEnv* env = vm.GetJNIEnv();
jstring request = env->NewStringUTF("{}");
jstring response = vm.DispatchMessageBusRequestHandler(
probe.account_handlers.at("DeviceIntegrityAvailable"), env, request);
EXPECT_EQ(nlohmann::json::parse(CopyJavaString(env, response)),
(nlohmann::json{{"support", false}}));
env->DeleteLocalRef(request);
if (response) env->DeleteLocalRef(response);
EXPECT_TRUE(composition.Shutdown().ok());
EXPECT_EQ(probe.account_handlers_cleared, 2);
g_probe = nullptr;
}
@@ -1023,7 +1107,7 @@ TEST(RobloxExperienceCompositionTest,
class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test {
protected:
struct Helper {
Helper() {
explicit Helper(const char* before_ready = "") {
char pattern[] = "/tmp/mocktail_surface_close_XXXXXX";
const char* created = mkdtemp(pattern);
if (created == nullptr) return;
@@ -1035,6 +1119,7 @@ import socket, sys
sys.stdin.buffer.read()
s = socket.socket(fileno=198)
s.settimeout(5)
)PY" << before_ready << R"PY(
s.send(b'MWVE' + bytes([1, 3, 0, 0]) + bytes(4))
while True:
packet = s.recv(1024 * 1024)
@@ -1071,6 +1156,14 @@ while True:
++static_cast<ExitProbe*>(context)->calls;
}
static Status Open(RobloxExperienceComposition* composition,
const std::shared_ptr<ExitProbe>& probe) {
return composition->OpenWebSurface(
"https://www.roblox.com/challenge/cdn/hybrid", "webview",
RobloxExperienceComposition::WebSurfaceRoute::kWebView,
{probe, &RobloxExperienceCompositionWebSurfaceTest::ObserveExit}, {});
}
static std::unique_ptr<RobloxExperienceComposition> MakeComposition(
const SecureRobloxCredential* credential = nullptr) {
return std::make_unique<RobloxExperienceComposition>(
@@ -1132,6 +1225,61 @@ while True:
uint64_t process_generation) {
composition->HandleWebSurfaceExit(process_generation);
}
static void MarkClosesWhenChallengeSolved(
RobloxExperienceComposition* composition) {
std::lock_guard<std::mutex> lock(composition->mutex_);
composition->web_surface_close_when_challenge_solved_ = true;
}
static bool SolvedChallengeClosePending(
RobloxExperienceComposition* composition) {
std::lock_guard<std::mutex> lock(composition->mutex_);
return composition->solved_challenge_close_deadline_.has_value();
}
static void ExpireSolvedChallengeGrace(
RobloxExperienceComposition* composition) {
std::lock_guard<std::mutex> lock(composition->mutex_);
if (composition->solved_challenge_close_deadline_.has_value()) {
composition->solved_challenge_close_deadline_ =
std::chrono::steady_clock::now();
}
}
static std::string ChallengeSuccess() {
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
R"("params":{"params":{"data":{"captchaData":{}},)"
R"("feature":"CaptchaSuccess"}},"callbackID":"challenge-1"})";
}
static std::unique_ptr<RobloxExperienceComposition> MakeWebViewComposition(
jnivm::VM* vm, Probe* probe) {
for (const char* class_name : {
"com/roblox/protocols/webview/WebViewProtocol",
"com/roblox/universalapp/messagebus/MessageBus",
"com/roblox/universalapp/messagebus/Connection",
"com/roblox/engine/jni/memstorage/MemStorage",
"com/roblox/engine/jni/memstorage/Connection",
"com/roblox/engine/jni/memstorage/Callback",
}) {
vm->RegisterClass(class_name);
}
return std::make_unique<RobloxExperienceComposition>(
JniEnvironmentProvider{vm->GetJavaVM(), vm, Prepare},
RobloxExperienceMessageBusSymbols{},
RobloxWebViewMessageBusSymbols{
GetWebViewOpenId, GetWebViewHandleWindowCloseId,
GetWebViewProtocolName, GetWebViewAvailabilityId,
GetWebViewMessageId, InitializeWebViewProtocol, Subscribe,
Disconnect, SetRequestHandler, ClearNativeRequestHandler,
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
GetWebViewCloseId, SignalWebViewJavascriptCallback,
UpdateCookieSetHandler},
BrowserServiceSymbols(), PermissionsSymbols(),
RobloxGameSessionSymbols{}, JniFactory(probe),
RobloxFreshLaunchPresentBoundary{});
}
};
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
@@ -1278,6 +1426,164 @@ TEST_F(RobloxExperienceCompositionWebSurfaceTest,
EXPECT_EQ(probe->calls, 1);
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
WaitsForSlowHelperStartupBeforeRejectingSurface) {
Helper helper("import time\ntime.sleep(3.5)\n");
ASSERT_FALSE(helper.path.empty());
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
auto composition = MakeComposition(&credential);
auto probe = std::make_shared<ExitProbe>();
::testing::internal::CaptureStderr();
const Status status = Open(composition.get(), probe);
const std::string log = ::testing::internal::GetCapturedStderr();
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
EXPECT_TRUE(status.ok()) << status.message();
const std::string marker = "Roblox web surface launched in ";
const size_t at = log.find(marker);
ASSERT_NE(at, std::string::npos) << log;
EXPECT_GE(std::stol(log.substr(at + marker.size())), 3500) << log;
EXPECT_TRUE(Close(composition.get()).ok());
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ReportsElapsedStartupTimeWhenHelperNeverBecomesReady) {
Helper helper("sys.exit(0)\n");
ASSERT_FALSE(helper.path.empty());
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
auto composition = MakeComposition(&credential);
auto probe = std::make_shared<ExitProbe>();
const Status status = Open(composition.get(), probe);
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
ASSERT_FALSE(status.ok());
EXPECT_NE(status.message().find("did not become ready"), std::string::npos)
<< status.message();
EXPECT_NE(status.message().find(" ms of 5000 ms"), std::string::npos)
<< status.message();
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ForwardsSolvedChallengeAndLeavesClosingToRoblox) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
const std::string success = ChallengeSuccess();
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
{WebViewHelperEventType::kRobloxWkHybrid,
success})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, success);
EXPECT_TRUE(SolvedChallengeClosePending(composition.get()));
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 0);
ASSERT_TRUE(Close(composition.get()).ok());
EXPECT_EQ(exit_probe->calls, 1);
ExpireSolvedChallengeGrace(composition.get());
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 1);
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ClosesSolvedChallengeWhenRobloxLeavesItOpen) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
{WebViewHelperEventType::kExecuteRoblox,
ChallengeSuccess()})
.ok());
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 0);
ExpireSolvedChallengeGrace(composition.get());
::testing::internal::CaptureStderr();
const Status status = composition->DrainPlatformEvents();
const std::string log = ::testing::internal::GetCapturedStderr();
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(exit_probe->calls, 1);
EXPECT_NE(log.find("closing solved challenge window"), std::string::npos)
<< log;
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
ClosesSolvedCaptchaWindowAfterForwardingSuccess) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
MarkClosesWhenChallengeSolved(composition.get());
const std::string success = ChallengeSuccess();
::testing::internal::CaptureStderr();
const Status status = RouteCurrentEvent(
composition.get(), 17, 23,
{WebViewHelperEventType::kRobloxWkHybrid, success});
const std::string log = ::testing::internal::GetCapturedStderr();
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(probe.web_view_javascript_callback, success);
EXPECT_EQ(exit_probe->calls, 1);
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
EXPECT_NE(log.find("closing solved captcha window"), std::string::npos)
<< log;
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
UnsolvedChallengeEventsKeepWindowOpen) {
jnivm::VM vm;
Probe probe{&vm};
g_probe = &probe;
auto composition = MakeWebViewComposition(&vm, &probe);
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
auto exit_probe = std::make_shared<ExitProbe>();
Activate(composition.get(), false, 17, 23, exit_probe);
const std::string shown =
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
R"("params":{"params":{"feature":"CaptchaShown"}},"callbackID":"c"})";
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
{WebViewHelperEventType::kRobloxWkHybrid,
shown})
.ok());
EXPECT_EQ(probe.web_view_javascript_callback, shown);
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
EXPECT_EQ(exit_probe->calls, 0);
EXPECT_TRUE(Close(composition.get()).ok());
EXPECT_TRUE(composition->Shutdown().ok());
g_probe = nullptr;
}
namespace {} // namespace
} // namespace runtime
} // namespace mocktail
+1 -1
View File
@@ -325,7 +325,7 @@ TEST_F(RobloxInputNativeAdapterTest, CallsExactExportedJniSignatures) {
(std::vector<double>{90.0, 190.0, 4.0, -2.0}));
EXPECT_EQ(probe_.calls[1].name, "mouse-button");
EXPECT_EQ(probe_.calls[1].arguments,
(std::vector<double>{100.0, 200.0, JNI_TRUE, 3.0}));
(std::vector<double>{100.0, 200.0, JNI_TRUE, 2.0}));
EXPECT_EQ(probe_.calls[2].name, "touch");
EXPECT_EQ(probe_.calls[2].arguments,
(std::vector<double>{0.0, 320.0, 360.0, 0.0, 1280.0, 720.0}));
+27 -7
View File
@@ -170,13 +170,13 @@ class RobloxInputRouterTest : public ::testing::Test {
RobloxInputRouter router_{Sink(&probe_)};
};
TEST(RobloxInputMappingTest, MapsAndroidMouseButtonsExactlyLikeApk) {
EXPECT_EQ(MapSdlMouseButtonToAndroid(SDL_BUTTON_LEFT), 0);
EXPECT_EQ(MapSdlMouseButtonToAndroid(SDL_BUTTON_RIGHT), 1);
EXPECT_EQ(MapSdlMouseButtonToAndroid(SDL_BUTTON_MIDDLE), 3);
EXPECT_EQ(MapSdlMouseButtonToAndroid(SDL_BUTTON_X1), 7);
EXPECT_EQ(MapSdlMouseButtonToAndroid(SDL_BUTTON_X2), 15);
EXPECT_EQ(MapSdlMouseButtonToAndroid(42), -1);
TEST(RobloxInputMappingTest, MapsMouseButtonsForNativeInput) {
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_LEFT), 0);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_RIGHT), 1);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_MIDDLE), 2);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_X1), 7);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_X2), 15);
EXPECT_EQ(MapSdlMouseButtonToRoblox(42), -1);
}
TEST(RobloxInputMappingTest, MapsSdlUsbKeysToLinuxAndAndroidCodes) {
@@ -239,6 +239,7 @@ TEST_F(RobloxInputRouterTest, RoutesMouseMotionButtonAndVerticalWheel) {
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].delta_y, -2.0f);
}
TEST_F(RobloxInputRouterTest, HoldsPointerPositionDuringRelativeMotion) {
// Relative-mode motion reports zero absolute coordinates. The pointer stays
// where it was captured while the deltas pass through, so a long camera
@@ -467,6 +468,25 @@ TEST_F(RobloxInputRouterTest, RawMouseSendsLookDeltasUnscaled) {
EXPECT_NEAR(probe_.mouse_moves[0].y,
(transform.guest_height() - 1.0F) / 2.0F, 0.01f);
}
TEST_F(RobloxInputRouterTest, MiddleClickUsesMouseButton3OnPressAndRelease) {
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_MIDDLE, 1, 100.0f, 80.0f}))
.dispatched());
EXPECT_EQ(router_.Snapshot().active_mouse_buttons, 1U);
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
false, SDL_BUTTON_MIDDLE, 1, 100.0f, 80.0f}))
.dispatched());
ASSERT_EQ(probe_.mouse_buttons.size(), 2U);
EXPECT_TRUE(probe_.mouse_buttons[0].pressed);
EXPECT_EQ(probe_.mouse_buttons[0].button, 2);
EXPECT_FALSE(probe_.mouse_buttons[1].pressed);
EXPECT_EQ(probe_.mouse_buttons[1].button, 2);
EXPECT_EQ(router_.Snapshot().active_mouse_buttons, 0U);
}
TEST_F(RobloxInputRouterTest,
ScalesMouseCoordinatesAfterDisplayScaleChange) {
+71
View File
@@ -322,6 +322,77 @@ TEST(RobloxLaunchUriTest, ClearsOutputAndRedactsTicketOnFailure) {
EXPECT_FALSE(ParseRobloxLaunchUri(kModernPublicLaunchUri, nullptr).ok());
}
TEST(RobloxWebViewLaunchGameTest, ParsesPrivateServerPlayButton) {
constexpr char kCommand[] =
R"({"moduleID":"Game","functionName":"launchGame",)"
R"("callbackID":"b8cf5fbd-f6a8-cfba-2cae-84bce46f46b2",)"
R"("params":{"request":{"requestType":"RequestPrivateGame",)"
R"("browserTrackerId":"1790271319250009",)"
R"("accessCode":"664b524f-19cc-4e28-a947-e8f5286f4179",)"
R"("placeId":"920587237","joinAttemptId":"attempt-1",)"
R"("joinAttemptOrigin":"privateServerListJoin",)"
R"("isPlayTogetherGame":false}}})";
RobloxExperienceLaunchRequest request;
const Status status = ParseRobloxWebViewLaunchGame(kCommand, &request);
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(request.place_id, 920587237);
EXPECT_EQ(request.access_code, "664b524f-19cc-4e28-a947-e8f5286f4179");
EXPECT_EQ(request.join_attempt_id, "attempt-1");
EXPECT_EQ(request.join_attempt_origin, "privateServerListJoin");
EXPECT_EQ(request.canonical_json.find("browserTrackerId"), std::string::npos);
ExpectCanonicalRequestMatches(request);
}
TEST(RobloxWebViewLaunchGameTest, ParsesServerJobWithNumericPlaceId) {
constexpr char kCommand[] =
R"({"moduleID":"Game","functionName":"launchGame",)"
R"("params":{"request":{"requestType":"RequestGameJob",)"
R"("placeId":1962086868,"gameId":"97ea-49e4"}}})";
RobloxExperienceLaunchRequest request;
const Status status = ParseRobloxWebViewLaunchGame(kCommand, &request);
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(request.place_id, 1962086868);
EXPECT_EQ(request.game_instance_id, "97ea-49e4");
ExpectCanonicalRequestMatches(request);
}
TEST(RobloxWebViewLaunchGameTest, ReportsOtherCommandsAsUnsupported) {
RobloxExperienceLaunchRequest request;
request.place_id = 123;
request.canonical_json = "stale";
const Status status = ParseRobloxWebViewLaunchGame(
R"({"moduleID":"Game","functionName":"getSystemInfo","params":{}})",
&request);
EXPECT_EQ(status.code(), StatusCode::kUnsupported);
EXPECT_EQ(request.place_id, 0);
EXPECT_TRUE(request.canonical_json.empty());
EXPECT_EQ(ParseRobloxWebViewLaunchGame("not json", &request).code(),
StatusCode::kUnsupported);
}
TEST(RobloxWebViewLaunchGameTest, RejectsMalformedLaunchGame) {
const char* const kCommands[] = {
R"({"functionName":"launchGame"})",
R"({"functionName":"launchGame","params":{"request":"x"}})",
R"({"functionName":"launchGame","params":{"request":{)"
R"("requestType":"RequestPrivateGame","placeId":"920587237"}}})",
R"({"functionName":"launchGame","params":{"request":{)"
R"("requestType":"RequestGame","placeId":"-1"}}})",
};
for (const char* command : kCommands) {
RobloxExperienceLaunchRequest request;
const Status status = ParseRobloxWebViewLaunchGame(command, &request);
EXPECT_EQ(status.code(), StatusCode::kInvalidArgument) << command;
EXPECT_EQ(request.place_id, 0) << command;
}
}
} // namespace
} // namespace runtime
} // namespace mocktail
+2
View File
@@ -594,6 +594,7 @@ TEST(RobloxTextDisplayStateTest, AdvancesCaretForTrailingSpace) {
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest, KeepsCaretBeforeTrailingSpaceDistinct) {
RobloxTextSurfaceOverlay overlay;
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
@@ -648,6 +649,7 @@ TEST(RobloxTextDisplayStateTest, ReopensFontsAfterPointSizeChange) {
EXPECT_TRUE(overlay.Shutdown().ok());
}
} // namespace
} // namespace runtime
} // namespace mocktail
+76
View File
@@ -162,6 +162,13 @@ void InitializeAndroidWebViewProtocol(JNIEnv *, jclass) {
g_web_view_probe->initialization_order.push_back("initialize");
}
int g_fake_protocol_holder = 0;
RobloxWebViewProtocolHandle AcquireWebViewProtocol() {
g_web_view_probe->initialization_order.push_back("acquire_protocol");
return RobloxWebViewProtocolHandle(&g_fake_protocol_holder);
}
void SignalJavascriptCallback(JNIEnv* env, jclass, jstring payload) {
g_web_view_probe->hybrid_callback_payload = ReadJavaString(env, payload);
}
@@ -473,6 +480,34 @@ TEST(RobloxWebViewParserTest, BuildsExactCaptchaRoutesWithoutGuessingSuccess) {
.ok());
}
TEST(RobloxWebViewParserTest, RecognizesOnlyChallengeSuccessNavigation) {
const auto navigation = [](const std::string& feature) {
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
R"("params":{"params":{"data":{"captchaData":{}},"feature":")" +
feature + R"("}},"callbackID":"challenge-1"})";
};
EXPECT_TRUE(IsRobloxWebViewChallengeSuccess(navigation("CaptchaSuccess")));
EXPECT_TRUE(
IsRobloxWebViewChallengeSuccess(navigation("challengeCompleted")));
EXPECT_TRUE(
IsRobloxWebViewChallengeSuccess(navigation("ChallengeCompleted")));
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(navigation("CaptchaShown")));
EXPECT_FALSE(
IsRobloxWebViewChallengeSuccess(navigation("ChallengeDisplayed")));
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(
R"({"moduleID":"Game","functionName":"navigateToFeature",)"
R"("params":{"params":{"feature":"CaptchaSuccess"}}})"));
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
R"("params":{"feature":"CaptchaSuccess"}})"));
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(
R"({"moduleID":7,"functionName":["navigateToFeature"],)"
R"("params":{"params":{"feature":{"CaptchaSuccess":true}}}})"));
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess("CaptchaSuccess"));
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(""));
}
TEST(RobloxWebViewParserTest, AcceptsOnlyCanonicalProfileRoutes) {
RobloxWebViewOpenRequest request;
Status status = ParseRobloxProfileViewUrl(
@@ -656,6 +691,7 @@ TEST(RobloxWebViewBridgeTest,
ASSERT_EQ(probe.dispatches, 2);
EXPECT_EQ(probe.request.url,
"https://www.roblox.com/games/servers-section/10148749921");
EXPECT_FALSE(probe.request.close_when_challenge_solved);
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
EXPECT_EQ(probe.data_model_focus_states,
(std::vector<std::string>{"Unfocused"}));
@@ -677,6 +713,7 @@ TEST(RobloxWebViewBridgeTest,
ASSERT_EQ(probe.dispatches, 3);
EXPECT_EQ(probe.request.url, "https://www.roblox.com/login");
EXPECT_EQ(probe.request.title, "Roblox sign in");
EXPECT_TRUE(probe.request.close_when_challenge_solved);
EXPECT_TRUE(bridge.HandleCloseWindow().ok());
EXPECT_EQ(probe.close_dispatches, 1);
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
@@ -1218,6 +1255,45 @@ TEST(RobloxWebViewBridgeTest,
g_web_view_probe = nullptr;
}
TEST(RobloxWebViewBridgeTest, HoldsWebViewProtocolFromBeforeNativeInitialization) {
const ScopedNativeLogin native_login(false);
jnivm::VM vm;
JNIEnv* env = vm.GetJNIEnv();
jclass bus_class =
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
jobject bus = env->AllocObject(bus_class);
WebViewBridgeProbe probe;
g_web_view_probe = &probe;
{
RobloxWebViewBridge bridge(
{vm.GetJavaVM(), nullptr, nullptr},
{&GetOpenWindowId, &GetHandleWindowCloseId, &GetProtocolName,
&GetIsAvailableId, &GetMessageId, &InitializeAndroidWebViewProtocol,
&SubscribeRaw, &DeleteConnection, &SetRequestHandler,
&ClearRequestHandler, &PublishRaw, &BroadcastDataModelFocus,
&GetMutateWindowId, &GetCloseWindowId, &SignalJavascriptCallback,
&UpdateCookieSetHandler, &AcquireWebViewProtocol},
{bus, &vm, &CreateRawCallback, &ClearRawCallback, &CreateRequestHandler,
&ClearRequestHandlerObject, &SetPlatformWebCallbacks,
&ClearPlatformWebCallbacks},
{&probe, &DispatchOpen, &DispatchMutate, &DispatchClose,
&DispatchCookie});
ASSERT_TRUE(bridge.Initialize().ok());
ASSERT_TRUE(bridge.Shutdown().ok());
}
// Roblox's own app start keeps this handle for the process lifetime.
// Releasing it deletes the protocol and its handleJavascriptCallback
// subscriber.
EXPECT_EQ(probe.initialization_order,
(std::vector<std::string>{
"message_id:openWindow", "message_id:mutateWindow",
"message_id:closeWindow", "availability", "subscribe",
"subscribe", "subscribe", "acquire_protocol", "initialize"}));
g_web_view_probe = nullptr;
}
} // namespace
} // namespace runtime
} // namespace mocktail
+11 -8
View File
@@ -1,6 +1,8 @@
// Modified by vii from komaruworld/mocktail. See README "About this fork".
#include "runtime/runtime_paths.h"
#include "compat/guest_abi.h"
#include <gtest/gtest.h>
#include <unistd.h>
@@ -94,8 +96,8 @@ void WriteManagedPayloadFiles(const std::filesystem::path& payload) {
ASSERT_TRUE(RuntimePaths::EnsureDirectory(payload / "sober_apk"));
std::ofstream(payload / "libroblox.so") << "ELF fixture";
std::ofstream(payload / "sober_apk/base.apk") << "base APK fixture";
std::ofstream(payload / "sober_apk/split_config.x86_64.apk")
<< "x86_64 split APK fixture";
std::ofstream(payload / "sober_apk" / compat::kGuestSplitApkFile)
<< "split APK fixture";
}
TEST(RuntimePathsTest, DerivesXdgDefaults) {
@@ -175,8 +177,8 @@ TEST(RuntimePathsTest, ResolvesValidatedActivePayload) {
EXPECT_EQ(active.root, std::filesystem::canonical(payload));
EXPECT_EQ(active.roblox_library, active.root / "libroblox.so");
EXPECT_EQ(active.base_apk, active.root / "sober_apk/base.apk");
EXPECT_EQ(active.x86_64_split_apk,
active.root / "sober_apk/split_config.x86_64.apk");
EXPECT_EQ(active.arch_split_apk,
active.root / "sober_apk" / compat::kGuestSplitApkFile);
EXPECT_EQ(active.assets_content, active.root / "assets/content");
EXPECT_EQ(paths.DefaultAssetPath(), active.assets_content);
}
@@ -190,7 +192,7 @@ TEST(RuntimePathsTest, RejectsIncompleteActiveAndroidPackage) {
const std::filesystem::path payload = data / "payloads" / payload_id;
WriteManagedPayloadFiles(payload);
ASSERT_TRUE(std::filesystem::remove(
payload / "sober_apk/split_config.x86_64.apk"));
payload / "sober_apk" / compat::kGuestSplitApkFile));
std::ofstream(data / "current.json")
<< "{\"schema_version\":1,\"payload_id\":\"" << payload_id
<< "\",\"payload_path\":\"payloads/" << payload_id
@@ -206,7 +208,8 @@ TEST(RuntimePathsTest, RejectsIncompleteActiveAndroidPackage) {
.ResolveActivePayload();
EXPECT_FALSE(active);
EXPECT_NE(active.error.find("split_config.x86_64.apk"), std::string::npos);
EXPECT_NE(active.error.find(std::string(compat::kGuestSplitApkFile)),
std::string::npos);
}
TEST(RuntimePathsTest, ResolvesCompleteApprovedProfileReferences) {
@@ -497,8 +500,8 @@ TEST(RuntimePathsTest, PreparesManagedPayloadRelativeAssetRoot) {
active.root = payload;
active.roblox_library = payload / "libroblox.so";
active.base_apk = payload / "sober_apk/base.apk";
active.x86_64_split_apk =
payload / "sober_apk/split_config.x86_64.apk";
active.arch_split_apk =
payload / "sober_apk" / compat::kGuestSplitApkFile;
active.assets_content = payload / "assets/content";
std::string error;
+172 -6
View File
@@ -123,7 +123,9 @@ constexpr std::uintptr_t kStagingBuffer = 0x500;
constexpr std::uintptr_t kStagingMemory = 0x600;
std::array<std::uint8_t, 64> g_source{};
std::array<std::uint8_t, 512> g_staging{};
// Every staging allocation maps here. Staging blocks are larger, but these
// tests write only the first few kilobytes of any block.
std::array<std::uint8_t, 64 * 1024> g_staging{};
VkExtent3D g_created_extent{};
VkExtent3D g_copied_extent{};
VkDeviceSize g_created_buffer_size = 0;
@@ -165,14 +167,14 @@ VKAPI_ATTR VkResult VKAPI_CALL FakeCreateBuffer(VkDevice,
const VkAllocationCallbacks*,
VkBuffer* buffer) {
g_created_buffer_size = info->size;
*buffer = FakeHandle<VkBuffer>(kStagingBuffer + g_create_buffer_calls);
++g_create_buffer_calls;
*buffer = FakeHandle<VkBuffer>(kStagingBuffer);
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL FakeBufferRequirements(
VkDevice, VkBuffer, VkMemoryRequirements* requirements) {
requirements->size = g_staging.size();
requirements->size = g_created_buffer_size;
requirements->memoryTypeBits = 1;
}
@@ -232,11 +234,21 @@ VKAPI_ATTR void VKAPI_CALL FakePipelineBarrier(
++g_pending_barriers;
}
// Staging buffer and offset of each recorded copy's first region.
struct StagedCopy {
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceSize offset = 0;
};
std::vector<StagedCopy> g_staged_copies;
VKAPI_ATTR void VKAPI_CALL FakeCopyBufferToImage(
VkCommandBuffer, VkBuffer, VkImage, VkImageLayout, std::uint32_t count,
const VkBufferImageCopy* regions) {
VkCommandBuffer, VkBuffer buffer, VkImage, VkImageLayout,
std::uint32_t count, const VkBufferImageCopy* regions) {
g_copied_extent = count > 0 ? regions[0].imageExtent : VkExtent3D{};
g_barriers_before_copy = g_pending_barriers;
if (count > 0) {
g_staged_copies.push_back({buffer, regions[0].bufferOffset});
}
}
constexpr std::uintptr_t kTimelineSemaphore = 0x900;
@@ -516,7 +528,6 @@ TEST(VulkanEtc2EmulationTest, ClampsScaledUploadsToTheHostMip) {
&region);
EXPECT_EQ(g_copied_extent.width, 2u);
EXPECT_EQ(g_copied_extent.height, 2u);
EXPECT_EQ(g_created_buffer_size, 2u * 2u * 4u);
// Differential block: every texel decodes to 134.
const std::array<std::uint8_t, 8> block = {0x81, 0x81, 0x81, 0x02,
@@ -734,6 +745,161 @@ TEST(VulkanEtc2EmulationTest, ReusesStagingBuffersAcrossCommandBuffers) {
EXPECT_EQ(g_free_memory_calls, 1u);
}
// Registers `device` and creates an EAC R11 image of `extent` whose uploads
// read from `source`. EAC is never upscaled, so staging bytes are the
// decoded size: two bytes per texel.
VkImage SetUpStagingTest(VulkanEtc2Emulation& emulation, VkDevice device,
VkBuffer source, VkExtent3D extent) {
VkPhysicalDeviceMemoryProperties memory{};
memory.memoryTypeCount = 1;
memory.memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x28), true,
memory, FakeGetDeviceProcAddr);
VkImageCreateInfo image_info{};
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
image_info.format = VK_FORMAT_EAC_R11_UNORM_BLOCK;
image_info.imageType = VK_IMAGE_TYPE_2D;
image_info.extent = extent;
image_info.mipLevels = 1;
image_info.arrayLayers = 1;
VkImage image = VK_NULL_HANDLE;
EXPECT_EQ(emulation.CreateImage(device, &image_info, nullptr, &image),
VK_SUCCESS);
EXPECT_EQ(emulation.BindBufferMemory(
device, source, FakeHandle<VkDeviceMemory>(kSourceMemory), 0),
VK_SUCCESS);
g_create_buffer_calls = 0;
g_destroy_buffer_calls = 0;
g_staged_copies.clear();
return image;
}
void RecordUpload(VulkanEtc2Emulation& emulation, VkDevice device,
VkCommandBuffer command_buffer, VkBuffer source,
VkImage image, VkExtent3D extent) {
VkBufferImageCopy region{};
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
region.imageSubresource.layerCount = 1;
region.imageExtent = extent;
emulation.CmdCopyBufferToImage(device, command_buffer, source, image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
&region);
}
// One buffer and allocation per upload costs the render thread a driver call
// on each side of the upload's life, so uploads share large blocks.
TEST(VulkanEtc2EmulationTest, SubAllocatesUploadsFromOneStagingBlock) {
const VkDevice device = FakeHandle<VkDevice>(0x18);
const VkBuffer source = FakeHandle<VkBuffer>(0x208);
VulkanEtc2Emulation emulation;
const VkImage image = SetUpStagingTest(emulation, device, source, {4, 4, 1});
std::vector<VkCommandBuffer> command_buffers;
for (std::uintptr_t index = 0; index < 8; ++index) {
command_buffers.push_back(FakeHandle<VkCommandBuffer>(0x790 + index));
RecordUpload(emulation, device, command_buffers.back(), source, image,
{4, 4, 1});
}
EXPECT_EQ(g_create_buffer_calls, 1u);
ASSERT_EQ(g_staged_copies.size(), 8u);
for (std::size_t index = 0; index < g_staged_copies.size(); ++index) {
EXPECT_EQ(g_staged_copies[index].buffer, g_staged_copies[0].buffer);
EXPECT_EQ(g_staged_copies[index].offset % 4, 0u) << index;
if (index > 0) {
// Each upload takes 4 x 4 texels of 2 bytes, clear of the one before.
EXPECT_GE(g_staged_copies[index].offset,
g_staged_copies[index - 1].offset + 4 * 4 * 2)
<< index;
}
}
for (VkCommandBuffer command_buffer : command_buffers) {
emulation.ReleaseCommandBuffer(command_buffer);
}
EXPECT_EQ(g_destroy_buffer_calls, 0u);
emulation.DestroyDevice(device);
EXPECT_EQ(g_destroy_buffer_calls, 1u);
}
TEST(VulkanEtc2EmulationTest, StartsANewStagingBlockWhenTheCurrentIsFull) {
const VkDevice device = FakeHandle<VkDevice>(0x19);
const VkBuffer source = FakeHandle<VkBuffer>(0x209);
VulkanEtc2Emulation emulation(1024);
// 16 x 16 texels of 2 bytes: two uploads fill a block.
const VkImage image =
SetUpStagingTest(emulation, device, source, {16, 16, 1});
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x7a0);
for (int upload = 0; upload < 3; ++upload) {
RecordUpload(emulation, device, command_buffer, source, image,
{16, 16, 1});
}
EXPECT_EQ(g_create_buffer_calls, 2u);
ASSERT_EQ(g_staged_copies.size(), 3u);
EXPECT_EQ(g_staged_copies[1].buffer, g_staged_copies[0].buffer);
EXPECT_NE(g_staged_copies[2].buffer, g_staged_copies[0].buffer);
EXPECT_EQ(g_staged_copies[2].offset, 0u);
emulation.ReleaseCommandBuffer(command_buffer);
}
TEST(VulkanEtc2EmulationTest, GivesAnUploadLargerThanABlockItsOwnBlock) {
const VkDevice device = FakeHandle<VkDevice>(0x1a);
const VkBuffer source = FakeHandle<VkBuffer>(0x20a);
VulkanEtc2Emulation emulation(1024);
const VkImage image =
SetUpStagingTest(emulation, device, source, {32, 32, 1});
const VkCommandBuffer command_buffer = FakeHandle<VkCommandBuffer>(0x7a1);
RecordUpload(emulation, device, command_buffer, source, image, {32, 32, 1});
EXPECT_EQ(g_create_buffer_calls, 1u);
EXPECT_GE(g_created_buffer_size, 32u * 32u * 2u);
emulation.ReleaseCommandBuffer(command_buffer);
}
TEST(VulkanEtc2EmulationTest, RefillsAStagingBlockOnceItsCopiesAreReleased) {
const VkDevice device = FakeHandle<VkDevice>(0x1b);
const VkBuffer source = FakeHandle<VkBuffer>(0x20b);
VulkanEtc2Emulation emulation(1024);
const VkImage image =
SetUpStagingTest(emulation, device, source, {16, 16, 1});
const VkCommandBuffer first = FakeHandle<VkCommandBuffer>(0x7a2);
const VkCommandBuffer second = FakeHandle<VkCommandBuffer>(0x7a3);
RecordUpload(emulation, device, first, source, image, {16, 16, 1});
RecordUpload(emulation, device, first, source, image, {16, 16, 1});
emulation.ReleaseCommandBuffer(first);
RecordUpload(emulation, device, second, source, image, {16, 16, 1});
EXPECT_EQ(g_create_buffer_calls, 1u);
ASSERT_EQ(g_staged_copies.size(), 3u);
EXPECT_EQ(g_staged_copies[2].buffer, g_staged_copies[0].buffer);
EXPECT_EQ(g_staged_copies[2].offset, 0u);
emulation.ReleaseCommandBuffer(second);
}
TEST(VulkanEtc2EmulationTest, KeepsFourIdleStagingBlocksAndDestroysTheRest) {
const VkDevice device = FakeHandle<VkDevice>(0x1c);
const VkBuffer source = FakeHandle<VkBuffer>(0x20c);
VulkanEtc2Emulation emulation(1024);
const VkImage image =
SetUpStagingTest(emulation, device, source, {16, 16, 1});
// Six command buffers each fill a block of their own.
std::vector<VkCommandBuffer> command_buffers;
for (std::uintptr_t index = 0; index < 6; ++index) {
command_buffers.push_back(FakeHandle<VkCommandBuffer>(0x7b0 + index));
RecordUpload(emulation, device, command_buffers.back(), source, image,
{16, 16, 1});
RecordUpload(emulation, device, command_buffers.back(), source, image,
{16, 16, 1});
}
ASSERT_EQ(g_create_buffer_calls, 6u);
for (VkCommandBuffer command_buffer : command_buffers) {
emulation.ReleaseCommandBuffer(command_buffer);
}
EXPECT_EQ(g_destroy_buffer_calls, 2u);
const VkCommandBuffer again = FakeHandle<VkCommandBuffer>(0x7c0);
RecordUpload(emulation, device, again, source, image, {16, 16, 1});
EXPECT_EQ(g_create_buffer_calls, 6u);
emulation.ReleaseCommandBuffer(again);
}
// Copies out of a scaled image cover its host bounds and shrink back onto
// an unscaled destination, which a plain copy cannot do.
TEST(VulkanEtc2EmulationTest, BlitsCopiesThatTouchScaledImages) {
+16 -5
View File
@@ -9,6 +9,8 @@
#include "runtime/graphics_launch_policy.h"
#include "compat/guest_abi.h"
namespace mocktail {
namespace runtime {
namespace {
@@ -40,16 +42,22 @@ void WriteManifest(const std::filesystem::path& path) {
TEST(VulkanIcdSelectionTest, PrefersTheNativeArchitectureManifest) {
TemporaryDirectory temporary;
WriteManifest(temporary.root() / "nvidia_icd.i686.json");
WriteManifest(temporary.root() / "nvidia_icd.x86_64.json");
const std::string native_name =
"nvidia_icd." + std::string(compat::kGuestCpuName) + ".json";
WriteManifest(temporary.root() / native_name);
EXPECT_EQ(SelectVulkanIcdManifest({temporary.root()}, "nvidia_icd"),
(temporary.root() / "nvidia_icd.x86_64.json").string());
(temporary.root() / native_name).string());
}
TEST(VulkanIcdSelectionTest, NeverSelectsAForeignArchitectureManifest) {
TemporaryDirectory temporary;
WriteManifest(temporary.root() / "nvidia_icd.i686.json");
#if defined(__aarch64__)
WriteManifest(temporary.root() / "radeon_icd.x86_64.json");
#else
WriteManifest(temporary.root() / "radeon_icd.aarch64.json");
#endif
EXPECT_TRUE(
SelectVulkanIcdManifest({temporary.root()}, "nvidia_icd").empty());
@@ -70,7 +78,8 @@ TEST(VulkanIcdSelectionTest, KeepsDirectoryPrecedenceOverName) {
const std::filesystem::path first = temporary.root() / "first";
const std::filesystem::path second = temporary.root() / "second";
WriteManifest(first / "nvidia_icd.json");
WriteManifest(second / "nvidia_icd.x86_64.json");
WriteManifest(second / ("nvidia_icd." +
std::string(compat::kGuestCpuName) + ".json"));
EXPECT_EQ(SelectVulkanIcdManifest({first, second}, "nvidia_icd"),
(first / "nvidia_icd.json").string());
@@ -78,13 +87,15 @@ TEST(VulkanIcdSelectionTest, KeepsDirectoryPrecedenceOverName) {
TEST(VulkanIcdSelectionTest, IsStableWhateverTheDirectoryOrderIs) {
TemporaryDirectory temporary;
WriteManifest(temporary.root() / "nvidia_icd.x86_64.json");
const std::string native_name =
"nvidia_icd." + std::string(compat::kGuestCpuName) + ".json";
WriteManifest(temporary.root() / native_name);
WriteManifest(temporary.root() / "nvidia_icd.json");
WriteManifest(temporary.root() / "nvidia_icd.i686.json");
const std::string selected =
SelectVulkanIcdManifest({temporary.root()}, "nvidia_icd");
EXPECT_EQ(selected, (temporary.root() / "nvidia_icd.x86_64.json").string());
EXPECT_EQ(selected, (temporary.root() / native_name).string());
EXPECT_EQ(selected, SelectVulkanIcdManifest({temporary.root()},
"nvidia_icd"));
}
+198
View File
@@ -0,0 +1,198 @@
#include "compat/web_view_protocol_contract.h"
#include <gtest/gtest.h>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
namespace mocktail::compat {
namespace {
std::string FromHex(std::string_view hex) {
std::string bytes;
for (std::size_t index = 0; index + 1 < hex.size(); index += 2) {
bytes += static_cast<char>(
std::stoi(std::string(hex.substr(index, 2)), nullptr, 16));
}
return bytes;
}
std::string Flipped(std::string code, std::size_t index) {
code[index] = static_cast<char>(code[index] ^ 0x01);
return code;
}
// Start of initializeAndroidWebViewProtocol and the getter it calls first,
// from the x86_64 and arm64-v8a libroblox.so of each named Roblox release.
struct KnownBuild {
const char* name;
std::uintptr_t initializer;
const char* initializer_code;
std::uintptr_t getter;
const char* getter_code;
};
constexpr KnownBuild kX86_64Builds[] = {
{"2.734.917", 0x2e7418f,
"554889e54157415641554154534883ec384c8b2599ce1004498b0424488945d0488d5da8"
"4889dfe8c9a355ff488b1b488b43184c",
0x23ce584,
"554889e553504889fb8a05ede9c10484c07419488d3dbae9c104e8590000004889034889"
"d84883c4085b5dc3"},
{"2.736.1408", 0x2e9d241,
"554889e54157415641554154534883ec484c8b25df291c04498b0424488945d0488d5d98"
"4889dfe8af2d56ff488b1b488b43184c",
0x240001c,
"554889e553504889fb8a05a5b9cc0484c07419488d3d72b9cc04e8590000004889034889"
"d84883c4085b5dc3"},
{"2.738.1397", 0x2e6c591,
"554889e54157415641554154534883ec184c8b2577d52704498b0424488945d0488d5dc0"
"4889dfe8fbf55cff488b1b488b43184c",
0x243bbb8,
"554889e553504889fb8a05f99dd10484c07419488d3dc69dd104e8590000004889034889"
"d84883c4085b5dc3"},
};
constexpr KnownBuild kArm64Builds[] = {
{"2.738.1397", 0x2bac4bc,
"ffc301d1fd7b02a9f91b00f9f85f04a9f65705a9f44f06a9fd83009137e101b0f77a43f9"
"e80240f9a8831ff8e8430091e82300915244de97f30740f9680e40f9",
0x233d638,
"fd7bbea9f30b00f9fd030091e92702f029e13391f30308aa29fddf0809010036e02702f0"
"0040339117000094600200f9f30b40f9fd7bc2a8c0035fd6"},
{"2.740.931", 0x2be87d8,
"ff0302d1fd7b03a9f92300f9f85f05a9f65706a9f44f07a9fdc3009157e801d0f7b244f9"
"e80240f9a8831ff8e84b0091e82300913a64de97f30740f9680e40f9",
0x23818f4,
"fd7bbea9f30b00f9fd030091492f02d029212a91f30308aa29fddf0809010036402f02d0"
"0080299117000094600200f9f30b40f9fd7bc2a8c0035fd6"},
};
TEST(WebViewProtocolContractTest, FindsX86_64GetterInKnownBuilds) {
for (const KnownBuild& build : kX86_64Builds) {
EXPECT_EQ(FindWebViewProtocolGetterCallX86_64(
FromHex(build.initializer_code), build.initializer),
std::optional<std::uintptr_t>(build.getter))
<< build.name;
const std::string getter = FromHex(build.getter_code);
ASSERT_EQ(getter.size(), kWebViewProtocolGetterBytesX86_64) << build.name;
EXPECT_TRUE(HasWebViewProtocolGetterContractX86_64(getter)) << build.name;
}
}
TEST(WebViewProtocolContractTest, RejectsChangedX86_64CallSite) {
const KnownBuild& build = kX86_64Builds[2];
const std::string code = FromHex(build.initializer_code);
// Frame setup, the handle slot's lea, the handle argument, the call, the
// handle load and the protocol field offset.
for (const std::size_t fixed : {0u, 3u, 32u, 34u, 36u, 39u, 44u, 50u}) {
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(Flipped(code, fixed),
build.initializer)
.has_value())
<< fixed;
}
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(code.substr(0, 50),
build.initializer)
.has_value());
}
TEST(WebViewProtocolContractTest, RejectsAmbiguousX86_64CallSite) {
const KnownBuild& build = kX86_64Builds[2];
const std::string code = FromHex(build.initializer_code);
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(code + code.substr(32, 19),
build.initializer)
.has_value());
}
TEST(WebViewProtocolContractTest, RejectsChangedX86_64Getter) {
const std::string getter = FromHex(kX86_64Builds[2].getter_code);
// Frame setup, the out-pointer save, the guard test, the holder lea, the
// local acquire call, the out-pointer store and the return.
for (const std::size_t fixed : {0u, 8u, 16u, 21u, 27u, 33u, 43u}) {
EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(Flipped(getter, fixed)))
<< fixed;
}
EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(
getter.substr(0, getter.size() - 1)));
}
TEST(WebViewProtocolContractTest, FindsArm64GetterInKnownBuilds) {
for (const KnownBuild& build : kArm64Builds) {
EXPECT_EQ(FindWebViewProtocolGetterCallArm64(
FromHex(build.initializer_code), build.initializer),
std::optional<std::uintptr_t>(build.getter))
<< build.name;
const std::string getter = FromHex(build.getter_code);
ASSERT_EQ(getter.size(), kWebViewProtocolGetterBytesArm64) << build.name;
EXPECT_TRUE(HasWebViewProtocolGetterContractArm64(getter)) << build.name;
}
}
TEST(WebViewProtocolContractTest, RejectsChangedArm64CallSite) {
const KnownBuild& build = kArm64Builds[0];
const std::string code = FromHex(build.initializer_code);
// Words 12..15: add x8, sp, #8; bl getter; ldr x19, [sp, #8];
// ldr x8, [x19, #0x18]. Byte 0 of each word holds its low register field.
EXPECT_FALSE(
FindWebViewProtocolGetterCallArm64(Flipped(code, 48), build.initializer)
.has_value());
std::string not_a_call = code;
not_a_call[55] = static_cast<char>(not_a_call[55] ^ 0x80); // bl -> b
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(not_a_call, build.initializer)
.has_value());
EXPECT_FALSE(
FindWebViewProtocolGetterCallArm64(Flipped(code, 56), build.initializer)
.has_value());
EXPECT_FALSE(
FindWebViewProtocolGetterCallArm64(Flipped(code, 60), build.initializer)
.has_value());
// The handle is read back from a different stack slot than the one passed.
std::string other_slot = code;
other_slot[57] = static_cast<char>(other_slot[57] ^ 0x04);
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(other_slot, build.initializer)
.has_value());
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(code.substr(0, 60),
build.initializer)
.has_value());
}
TEST(WebViewProtocolContractTest, RejectsAmbiguousArm64CallSite) {
const KnownBuild& build = kArm64Builds[0];
const std::string code = FromHex(build.initializer_code);
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(code + code.substr(48, 16),
build.initializer)
.has_value());
}
TEST(WebViewProtocolContractTest, RejectsChangedArm64Getter) {
const std::string getter = FromHex(kArm64Builds[0].getter_code);
// Frame setup, mov x19, x8 (the indirect result register), the guard
// branch, the local acquire call, the result store and the return.
for (const std::size_t fixed : {0u, 20u, 28u, 40u, 44u, 56u}) {
EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(Flipped(getter, fixed)))
<< fixed;
}
EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(
getter.substr(0, getter.size() - 1)));
}
TEST(WebViewProtocolContractTest, RejectsOtherArchitectureCode) {
const KnownBuild& x86_64 = kX86_64Builds[2];
const KnownBuild& arm64 = kArm64Builds[0];
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(
FromHex(x86_64.initializer_code), x86_64.initializer)
.has_value());
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(
FromHex(arm64.initializer_code), arm64.initializer)
.has_value());
EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(
FromHex(x86_64.getter_code) + std::string(16, '\0')));
EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(
FromHex(arm64.getter_code)));
}
} // namespace
} // namespace mocktail::compat
+6
View File
@@ -188,6 +188,12 @@ TEST(WebViewHelperPolicyTest, ParsesOnlyTypedCaptchaNavigationEvents) {
EXPECT_EQ(event.type, CaptchaEventType::kSuccess);
EXPECT_TRUE(event.callback_id.empty());
EXPECT_TRUE(ParseCaptchaEvent(
R"({"moduleID":"Navigation","functionName":"navigateToFeature","params":{"params":{"data":{"challengeType":"captcha"},"feature":"challengeCompleted"}},"callbackID":"challenge-complete-1"})",
&event));
EXPECT_EQ(event.type, CaptchaEventType::kSuccess);
EXPECT_EQ(event.callback_id, "challenge-complete-1");
for (
std::string_view rejected : {
R"({"moduleID":"Browser","functionName":"navigateToFeature","params":{"params":{"feature":"CaptchaSuccess"}}})",