mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19bd8406f | ||
|
|
cd46538eb8 | ||
|
|
eca5f395eb | ||
|
|
26767e925c | ||
|
|
01a0e40463 | ||
|
|
a11e833e06 | ||
|
|
b9bce36824 | ||
|
|
14c8a55211 | ||
|
|
5c5422fa02 | ||
|
|
9b041ec2ac | ||
|
|
5f83c4a64f | ||
|
|
a9d8e3185e | ||
|
|
67a9f4db97 | ||
|
|
f07daccc82 | ||
|
|
2eef4f966f | ||
|
|
41003efb09 | ||
|
|
29a0f03a90 | ||
|
|
5a27aa0366 | ||
|
|
c99a55dc54 | ||
|
|
30d50f15d1 | ||
|
|
ab940acfb5 | ||
|
|
719b1fdd22 | ||
|
|
1fa2e116b3 | ||
|
|
35359f3575 | ||
|
|
40237fae72 | ||
|
|
ad6a3c3147 |
No files matched your search
@@ -11,14 +11,14 @@ body:
|
||||
value: |
|
||||
Please include enough detail for someone else to reproduce the bug.
|
||||
|
||||
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
|
||||
Use any language you are comfortable with.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
Please write issues and pull requests yourself.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Mocktail version
|
||||
label: Nightcap version
|
||||
description: Provide the release version, package version, or commit hash.
|
||||
placeholder: "For example: v0.4.0 or 1a2b3c4"
|
||||
validations:
|
||||
@@ -37,7 +37,7 @@ body:
|
||||
id: installation
|
||||
attributes:
|
||||
label: Installation method
|
||||
description: Tell us how Mocktail was installed or built.
|
||||
description: Tell us how Nightcap was installed or built.
|
||||
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
|
||||
validations:
|
||||
required: true
|
||||
@@ -56,7 +56,7 @@ body:
|
||||
label: Steps to reproduce
|
||||
description: List the smallest reliable sequence of actions that triggers the problem.
|
||||
placeholder: |
|
||||
1. Start Mocktail with ...
|
||||
1. Start Nightcap with ...
|
||||
2. Open ...
|
||||
3. Select ...
|
||||
4. Observe ...
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Modified by vii from komaruworld/mocktail. See README "About this fork".
|
||||
name: Feature request
|
||||
description: Suggest an improvement or a new capability for Mocktail
|
||||
description: Suggest an improvement or a new capability for Nightcap
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
@@ -10,9 +11,9 @@ body:
|
||||
value: |
|
||||
Describe what you want to change and why it would be useful.
|
||||
|
||||
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
|
||||
Use any language you are comfortable with.
|
||||
|
||||
Please write issues and pull requests yourself. Do not use AI-generated text.
|
||||
Please write issues and pull requests yourself.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -26,7 +27,7 @@ body:
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe how you would like Mocktail to behave.
|
||||
description: Describe how you would like Nightcap to behave.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
|
||||
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
|
||||
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
|
||||
|
||||
Closes #
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -42,6 +42,18 @@ attach `latest.log`:
|
||||
|
||||
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
|
||||
|
||||
## Can I run multiple Roblox instances?
|
||||
|
||||
No. Nightcap won't add multi-instance launching. It would mean working around
|
||||
Roblox's restrictions on running several clients. The [Roblox Terms of Use](https://en.help.roblox.com/hc/en-us/articles/115004647846-Roblox-Terms-of-Use)
|
||||
prohibit bypassing technical protections, and violating the terms can get your
|
||||
account suspended.
|
||||
|
||||
Bloxstrap brought the option back in [v2.9.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.9.0)
|
||||
with a "use at your own risk" warning, then removed it in
|
||||
[v2.10.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.10.0)
|
||||
after Roblox added measures against running multiple clients.
|
||||
|
||||
## Can I play in VR?
|
||||
|
||||
VR is experimental and lives on upstream's `vr` branch, which does not
|
||||
|
||||
@@ -21,7 +21,7 @@ ANYLINUX_APPIMAGETOOL ?= appimagetool
|
||||
PREFIX ?= /usr
|
||||
FLATPAK_BUILD_DIR ?= build-flatpak
|
||||
FLATPAK_MANIFEST ?= packaging/flatpak/io.github.CoderDayton.nightcap.json
|
||||
FLATPAK_JOBS ?= 4
|
||||
FLATPAK_JOBS ?= 2
|
||||
|
||||
define build_native_runtime
|
||||
@git submodule update --init --recursive
|
||||
@@ -130,8 +130,8 @@ auto-run: ## Build/run loop with crash summaries
|
||||
run-gles: build ## Run strict system EGL/OpenGL ES 3 without Vulkan fallback
|
||||
@$(BINARY) --graphics opengl
|
||||
|
||||
run-angle: ## Run with ANGLE/Vulkan compatibility backend
|
||||
@MOCKTAIL_GRAPHICS_BACKEND=angle-vulkan ./scripts/run_sober.sh
|
||||
run-angle: build ## Run with ANGLE/Vulkan compatibility backend
|
||||
@$(BINARY) --graphics angle-vulkan
|
||||
|
||||
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
|
||||
@./scripts/update_roblox_payload.sh
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
|
||||
# Nightcap
|
||||
|
||||
[](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml)
|
||||
[](https://github.com/CoderDayton/nightcap/stargazers)
|
||||
[](LICENSE)
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
|
||||
<h1 style="margin-top: 5px;">
|
||||
Nightcap
|
||||
<br>
|
||||
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
Nightcap is Mocktail, tuned for playing on a real PC. It runs the Android
|
||||
`x86_64` Roblox client on Linux, including a Linux userspace hosted by
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -16,7 +16,7 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
|
||||
- Input tab: the `input.raw_mouse` toggle and controller options.
|
||||
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
|
||||
- Discord tab: every field in the `discord_rpc` block, so a friend can see
|
||||
what you are playing and you can switch it all off in one click.
|
||||
what you are playing and you can switch it all off in one click.
|
||||
- Changes apply on next launch. A restart button restarts Nightcap.
|
||||
|
||||
Done when every option in `config/mocktail.example.yaml` that a player would
|
||||
@@ -29,62 +29,53 @@ already hot-plug and release their held inputs when unplugged. One rough edge
|
||||
is left.
|
||||
|
||||
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
|
||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
||||
and product IDs, are available and unused.
|
||||
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
|
||||
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
|
||||
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
|
||||
and product IDs, are available and unused.
|
||||
|
||||
Done when a plugged-in pad shows its own buttons.
|
||||
|
||||
## 3. First-load stutter
|
||||
|
||||
The `--profile` trace now shows shader compiles and pipeline creation, so
|
||||
the stutter on first entering an experience can be measured.
|
||||
|
||||
- Cut the stutter the trace shows, and prove it with a before and after
|
||||
trace per [BENCHMARKING.md](BENCHMARKING.md).
|
||||
|
||||
Done when entering a fresh experience no longer drops frames on shader
|
||||
compiles.
|
||||
|
||||
## Shipped
|
||||
|
||||
- Lower input latency: frames are presented as soon as they are ready.
|
||||
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
|
||||
releases its held buttons and sticks instead of leaving them stuck.
|
||||
releases its held buttons and sticks instead of leaving them stuck.
|
||||
- Mouse look keeps responding after a long turn, instead of going dead until
|
||||
the pointer travels back from past the screen edge.
|
||||
the pointer travels back from past the screen edge.
|
||||
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
|
||||
deltas in host units, so fractional display scaling no longer changes
|
||||
first-person sensitivity.
|
||||
deltas in host units, so fractional display scaling no longer changes
|
||||
first-person sensitivity.
|
||||
- Small textures upscaled 4x by default, with PNG overrides in
|
||||
`~/.config/mocktail/textures`.
|
||||
`~/.config/mocktail/textures`.
|
||||
- ETC2 textures decoded on desktop GPUs without stutter.
|
||||
- No more error 319 kicks after joining a server.
|
||||
- Roblox sees the real host RAM and screen size.
|
||||
- Desktop launcher that works on Wayland and finds the installed binary.
|
||||
- Discord Rich Presence on by default, with the experience name, creator,
|
||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
||||
elapsed time, the experience icon with a Roblox badge, and a join button
|
||||
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
|
||||
- Custom Discord presence: title, details, state, and large and small icons
|
||||
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
|
||||
`{creator_name}` placeholders.
|
||||
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
|
||||
`{creator_name}` placeholders.
|
||||
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
|
||||
including shader and pipeline creation, readable in `ui.perfetto.dev`.
|
||||
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
|
||||
`scripts/summarize_profile_trace.py` builds the before and after table.
|
||||
including shader and pipeline creation, readable in `ui.perfetto.dev`.
|
||||
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
|
||||
`scripts/summarize_profile_trace.py` builds the before and after table.
|
||||
- Texture uploads no longer stall the frame they land on: ETC2 decode and
|
||||
resampling run on a persistent worker pool.
|
||||
resampling run on a persistent worker pool.
|
||||
- Less stutter while joining an experience and while textures load in. See
|
||||
the [changelog](../CHANGELOG.md) for the numbers.
|
||||
- The host main thread rests between engine polls instead of spinning, and
|
||||
GameMode's core pinning is released, so Roblox's workers spread across
|
||||
every core. See [PERFORMANCE.md](PERFORMANCE.md).
|
||||
GameMode's core pinning is released, so Roblox's workers spread across
|
||||
every core. See [PERFORMANCE.md](PERFORMANCE.md).
|
||||
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
|
||||
upstream.
|
||||
upstream.
|
||||
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
|
||||
repo on GitHub Pages.
|
||||
repo on GitHub Pages.
|
||||
|
||||
## Not planned
|
||||
|
||||
- Replacing the `mocktail` binary and config names. Keeping them makes
|
||||
upstream merges clean.
|
||||
- DEB, RPM, and AUR packages. Upstream ships those for unmodified Mocktail.
|
||||
upstream merges clean.
|
||||
|
||||
@@ -33,6 +33,23 @@ void SetLegacyBionicDiagnosticsEnabled(bool enabled);
|
||||
// guest layout explicit instead of exposing the incompatible host libc type.
|
||||
using MocktailBionicMutexAttr = int64_t;
|
||||
|
||||
// Bionic LP64 pthread_attr_t: 56 bytes with explicit field offsets. The host
|
||||
// glibc layout is opaque and wider on aarch64, so guest buffers must be read
|
||||
// and written through this struct only.
|
||||
struct MocktailBionicPthreadAttr {
|
||||
std::uint32_t flags;
|
||||
void* stack_base;
|
||||
std::size_t stack_size;
|
||||
std::size_t guard_size;
|
||||
std::int32_t sched_policy;
|
||||
std::int32_t sched_priority;
|
||||
char reserved[16];
|
||||
};
|
||||
static_assert(sizeof(MocktailBionicPthreadAttr) == 56);
|
||||
|
||||
inline constexpr std::uint32_t kMocktailBionicAttrFlagDetached = 0x1;
|
||||
inline constexpr std::uint32_t kMocktailBionicAttrFlagUserStack = 0x2;
|
||||
|
||||
extern "C" {
|
||||
|
||||
void mocktail_set_current_jni_env(void* env);
|
||||
@@ -84,6 +101,19 @@ int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
|
||||
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
|
||||
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
|
||||
|
||||
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
|
||||
size_t stack_size);
|
||||
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
|
||||
int detach_state);
|
||||
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
|
||||
const struct sched_param* parameters);
|
||||
int mocktail_pthread_getattr_np(pthread_t thread,
|
||||
MocktailBionicPthreadAttr* attr);
|
||||
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
|
||||
void** stack_base, size_t* stack_size);
|
||||
|
||||
extern void* mocktail_gameactivity_on_start_native;
|
||||
extern void* mocktail_gameactivity_on_resume_native;
|
||||
extern void* mocktail_gameactivity_on_surface_created_native;
|
||||
|
||||
@@ -25,11 +25,12 @@ void ConfigureBionicPthreadThreadInitializer(
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
struct MocktailBionicPthreadAttr;
|
||||
|
||||
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
|
||||
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
|
||||
const pthread_attr_t* attr,
|
||||
void* (*start_routine)(void*),
|
||||
void* argument);
|
||||
extern "C" int mocktail_bionic_pthread_create(
|
||||
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument);
|
||||
|
||||
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
|
||||
extern "C" int mocktail_bionic_pthread_setschedparam(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
#define MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
|
||||
#include <elf.h>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
#if defined(__aarch64__)
|
||||
// Android ABI name as it appears in APK lib/ directories and build metadata.
|
||||
inline constexpr std::string_view kGuestAbi = "arm64-v8a";
|
||||
// APK split-config name carrying the guest native libraries.
|
||||
inline constexpr std::string_view kGuestApkSplitName = "config.arm64_v8a";
|
||||
// File name the payload store uses for the split APK.
|
||||
inline constexpr std::string_view kGuestSplitApkFile =
|
||||
"split_config.arm64_v8a.apk";
|
||||
// sha256 map key holding the split APK digest in payload metadata.
|
||||
inline constexpr std::string_view kGuestSplitApkHashKey = "arm64_v8a_split_apk";
|
||||
// Short CPU label for device metadata and user-facing strings.
|
||||
inline constexpr std::string_view kGuestCpuName = "aarch64";
|
||||
inline constexpr int kGuestElfMachine = EM_AARCH64;
|
||||
#elif defined(__x86_64__)
|
||||
inline constexpr std::string_view kGuestAbi = "x86_64";
|
||||
inline constexpr std::string_view kGuestApkSplitName = "config.x86_64";
|
||||
inline constexpr std::string_view kGuestSplitApkFile = "split_config.x86_64.apk";
|
||||
inline constexpr std::string_view kGuestSplitApkHashKey = "x86_64_split_apk";
|
||||
inline constexpr std::string_view kGuestCpuName = "x86_64";
|
||||
inline constexpr int kGuestElfMachine = EM_X86_64;
|
||||
#else
|
||||
#error "mocktail only supports aarch64 and x86_64 hosts"
|
||||
#endif
|
||||
|
||||
// APK zip entry holding the guest libroblox.so.
|
||||
inline constexpr std::string_view kGuestLibApkEntryPrefix = "lib/";
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_GUEST_ABI_H_
|
||||
@@ -0,0 +1,190 @@
|
||||
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
|
||||
// stack slot, then reads the protocol from the returned holder:
|
||||
//
|
||||
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
|
||||
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
|
||||
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
|
||||
// ldr x8, [xN, #0x18]
|
||||
//
|
||||
// These functions find that call site in the initializer's first bytes and
|
||||
// check the getter's own code. Only relocated or build-varying fields (stack
|
||||
// slots, frame sizes, PC-relative offsets, the call target) are masked.
|
||||
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
|
||||
// 2.738.1397 and 2.740.931.
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
|
||||
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
|
||||
|
||||
namespace web_view_protocol_contract_detail {
|
||||
|
||||
inline bool MatchesMasked(std::string_view code, std::size_t offset,
|
||||
std::string_view pattern,
|
||||
std::string_view wildcard) {
|
||||
if (offset > code.size() || code.size() - offset < pattern.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t index = 0; index < pattern.size(); ++index) {
|
||||
if (wildcard[index] == 'x') continue;
|
||||
if (code[offset + index] != pattern[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// AArch64 instructions are little-endian words.
|
||||
inline std::uint32_t Word(std::string_view code, std::size_t index) {
|
||||
std::uint32_t word = 0;
|
||||
for (std::size_t byte = 0; byte < 4; ++byte) {
|
||||
word |= static_cast<std::uint32_t>(
|
||||
static_cast<unsigned char>(code[index * 4 + byte]))
|
||||
<< (8 * byte);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
// One AArch64 instruction word: bits outside `mask` may vary.
|
||||
struct MaskedWord {
|
||||
std::uint32_t mask;
|
||||
std::uint32_t value;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kExact = 0xffffffffU;
|
||||
// adrp Xd, page: immlo and immhi vary.
|
||||
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
|
||||
// add Xd, Xn, #imm12: imm12 varies.
|
||||
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
|
||||
|
||||
} // namespace web_view_protocol_contract_detail
|
||||
|
||||
// Returns the getter address called from the x86-64 initializer at
|
||||
// `address`, or nullopt unless the frame setup and exactly one call site
|
||||
// match.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::MatchesMasked;
|
||||
// push %rbp; mov %rsp,%rbp
|
||||
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
|
||||
return std::nullopt;
|
||||
}
|
||||
static constexpr std::string_view kCallSite(
|
||||
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
|
||||
"\x18",
|
||||
19);
|
||||
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
|
||||
static constexpr std::size_t kCallDisplacement = 8;
|
||||
const std::string_view window =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes);
|
||||
std::optional<std::size_t> call_site;
|
||||
for (std::size_t offset = 0; offset < window.size(); ++offset) {
|
||||
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
|
||||
if (call_site.has_value()) return std::nullopt;
|
||||
call_site = offset;
|
||||
}
|
||||
}
|
||||
if (!call_site.has_value()) return std::nullopt;
|
||||
std::int32_t displacement = 0;
|
||||
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
|
||||
sizeof(displacement));
|
||||
const std::uintptr_t next_instruction =
|
||||
address + *call_site + kCallDisplacement + sizeof(displacement);
|
||||
return next_instruction +
|
||||
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
|
||||
}
|
||||
|
||||
// The x86-64 getter stores the holder returned by the subsystem's acquire
|
||||
// into its result slot (%rdi) and returns that slot.
|
||||
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
|
||||
return web_view_protocol_contract_detail::MatchesMasked(
|
||||
code, 0,
|
||||
std::string_view(
|
||||
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
|
||||
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
|
||||
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
|
||||
kWebViewProtocolGetterBytesX86_64),
|
||||
"...........xxxx.......xxxx..................");
|
||||
}
|
||||
|
||||
// Returns the getter address called from the AArch64 initializer at
|
||||
// `address`, or nullopt unless exactly one call site matches.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::Word;
|
||||
const std::size_t words =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
|
||||
std::optional<std::uintptr_t> getter;
|
||||
for (std::size_t index = 0; index + 4 <= words; ++index) {
|
||||
const std::uint32_t slot_address = Word(code, index);
|
||||
const std::uint32_t call = Word(code, index + 1);
|
||||
const std::uint32_t handle_load = Word(code, index + 2);
|
||||
const std::uint32_t protocol_load = Word(code, index + 3);
|
||||
// add x8, sp, #slot
|
||||
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
|
||||
// bl getter
|
||||
if ((call & 0xfc000000U) != 0x94000000U) continue;
|
||||
// ldr xN, [sp, #slot]
|
||||
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
|
||||
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
|
||||
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
|
||||
// ldr x8, [xN, #0x18]
|
||||
const std::uint32_t handle_register = handle_load & 0x1fU;
|
||||
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
|
||||
if (getter.has_value()) return std::nullopt;
|
||||
// imm26 is a signed word offset from the bl instruction.
|
||||
const std::int64_t imm26 = call & 0x03ffffffU;
|
||||
const std::int64_t words_from_call =
|
||||
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
|
||||
getter = address + (index + 1) * 4 +
|
||||
static_cast<std::uintptr_t>(words_from_call * 4);
|
||||
}
|
||||
return getter;
|
||||
}
|
||||
|
||||
// The AArch64 getter saves its result slot (x8) and stores the holder
|
||||
// returned by the subsystem's acquire there.
|
||||
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
|
||||
using namespace web_view_protocol_contract_detail;
|
||||
static constexpr MaskedWord kGetter[] = {
|
||||
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
|
||||
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
|
||||
{kExact, 0x910003fdU}, // mov x29, sp
|
||||
{kAdrpMask, 0x90000009U}, // adrp x9, guard
|
||||
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
|
||||
{kExact, 0xaa0803f3U}, // mov x19, x8
|
||||
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
|
||||
{kExact, 0x36000109U}, // tbz w9, #0, initialize
|
||||
{kAdrpMask, 0x90000000U}, // adrp x0, holder
|
||||
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
|
||||
{kExact, 0x94000017U}, // bl acquire
|
||||
{kExact, 0xf9000260U}, // str x0, [x19]
|
||||
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
|
||||
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
|
||||
{kExact, 0xd65f03c0U}, // ret
|
||||
};
|
||||
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
|
||||
kWebViewProtocolGetterBytesArm64);
|
||||
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
|
||||
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
|
||||
++index) {
|
||||
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
@@ -48,9 +48,15 @@ struct PlatformIdentity {
|
||||
std::string manufacturer = "Mocktail";
|
||||
std::string model = "Mocktail Headless";
|
||||
std::string brand = "Mocktail";
|
||||
#if defined(__aarch64__)
|
||||
std::string device_code = "linux-arm64";
|
||||
std::string device_sku = "mocktail-arm64";
|
||||
std::string soc_model = "aarch64";
|
||||
#else
|
||||
std::string device_code = "linux-x86_64";
|
||||
std::string device_sku = "mocktail-x86_64";
|
||||
std::string soc_model = "x86_64";
|
||||
#endif
|
||||
};
|
||||
|
||||
struct RobloxCredentialView {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#if defined(__x86_64__)
|
||||
#include <xmmintrin.h>
|
||||
#endif
|
||||
#if defined(__aarch64__)
|
||||
#include <cstdint>
|
||||
#endif
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
@@ -22,16 +25,23 @@ class FmodThreadFloatingPointMode final {
|
||||
delete;
|
||||
|
||||
bool Enable(const char* thread_name) noexcept {
|
||||
#if defined(__x86_64__)
|
||||
#if defined(__x86_64__) || defined(__aarch64__)
|
||||
if (active_ || thread_name == nullptr) return false;
|
||||
const std::string_view name(thread_name);
|
||||
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
|
||||
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
|
||||
return false;
|
||||
}
|
||||
#if defined(__x86_64__)
|
||||
const unsigned int control = _mm_getcsr();
|
||||
original_mode_ = control & kModeMask;
|
||||
_mm_setcsr(control | kModeMask);
|
||||
#else
|
||||
std::uint64_t control;
|
||||
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
|
||||
original_mode_ = control & kModeMask;
|
||||
__asm__ volatile("msr fpcr, %0" ::"r"(control | kModeMask));
|
||||
#endif
|
||||
active_ = true;
|
||||
return true;
|
||||
#else
|
||||
@@ -41,11 +51,18 @@ class FmodThreadFloatingPointMode final {
|
||||
}
|
||||
|
||||
void Restore() noexcept {
|
||||
#if defined(__x86_64__)
|
||||
#if defined(__x86_64__) || defined(__aarch64__)
|
||||
if (active_) {
|
||||
// Restore only our two bits, preserving rounding, exception masks and
|
||||
// status flags set by the guest while the thread was attached.
|
||||
#if defined(__x86_64__)
|
||||
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
|
||||
#else
|
||||
std::uint64_t control;
|
||||
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
|
||||
__asm__ volatile("msr fpcr, %0" ::"r"((control & ~kModeMask) |
|
||||
original_mode_));
|
||||
#endif
|
||||
active_ = false;
|
||||
}
|
||||
#endif
|
||||
@@ -56,6 +73,13 @@ class FmodThreadFloatingPointMode final {
|
||||
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
|
||||
unsigned int original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#elif defined(__aarch64__)
|
||||
// FPCR FZ flushes single/double subnormals; FZ16 covers the half-precision
|
||||
// path used by some voice DSP filters.
|
||||
static constexpr std::uint64_t kModeMask =
|
||||
(std::uint64_t{1} << 24) | (std::uint64_t{1} << 19);
|
||||
std::uint64_t original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,35 @@ struct TraceArg {
|
||||
|
||||
// CLOCK_MONOTONIC in nanoseconds.
|
||||
std::uint64_t TraceClockNanos();
|
||||
// CPU time used so far by the calling thread, in nanoseconds.
|
||||
std::uint64_t TraceThreadCpuNanos();
|
||||
// CPU time used so far by every thread of the process, in nanoseconds.
|
||||
std::uint64_t TraceProcessCpuNanos();
|
||||
|
||||
// CPU time spent between two successive presents.
|
||||
struct FrameCpuSample {
|
||||
double process_ms = 0;
|
||||
// Set only when the previous present came from the same thread; thread CPU
|
||||
// clocks of different threads cannot be subtracted.
|
||||
bool has_thread = false;
|
||||
double thread_ms = 0;
|
||||
};
|
||||
|
||||
// Turns CPU clocks read at each present into per-frame CPU time.
|
||||
// Thread-safe.
|
||||
class FrameCpuSampler final {
|
||||
public:
|
||||
// Returns false for the first present, which ends no frame.
|
||||
bool Sample(std::uint64_t thread_id, std::uint64_t thread_cpu_ns,
|
||||
std::uint64_t process_cpu_ns, FrameCpuSample* sample);
|
||||
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
bool has_previous_ = false;
|
||||
std::uint64_t previous_thread_id_ = 0;
|
||||
std::uint64_t previous_thread_cpu_ns_ = 0;
|
||||
std::uint64_t previous_process_cpu_ns_ = 0;
|
||||
};
|
||||
|
||||
// Writes the Trace Event Format JSON array read by chrome://tracing and
|
||||
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
|
||||
|
||||
@@ -47,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;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Exact JNI entrypoints behind the APK AccountProtocol device-integrity
|
||||
// handlers: JNIAccountProtocol name getters and MessageBus raw handlers.
|
||||
struct RobloxAccountProtocolSymbols {
|
||||
using GetStringFn = jstring (*)(JNIEnv*, jclass);
|
||||
|
||||
GetStringFn get_protocol_name = nullptr;
|
||||
GetStringFn get_device_integrity_available_method_name = nullptr;
|
||||
GetStringFn get_integrity_token_method_name = nullptr;
|
||||
GetStringFn get_support_key = nullptr;
|
||||
GetStringFn get_token_key = nullptr;
|
||||
GetStringFn get_result_key = nullptr;
|
||||
void (*set_request_handler_raw)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_request_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_protocol_name && get_device_integrity_available_method_name &&
|
||||
get_integrity_token_method_name && get_support_key &&
|
||||
get_token_key && get_result_key && set_request_handler_raw &&
|
||||
clear_request_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxAccountProtocolObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_request_handler)(void*, std::shared_ptr<void>,
|
||||
std::string (*)(void*, JNIEnv*,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_request_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_request_handler &&
|
||||
clear_request_handler;
|
||||
}
|
||||
};
|
||||
|
||||
// Answers AccountProtocol device-integrity requests for a host that cannot
|
||||
// produce Play Integrity tokens. No integrity token is ever produced.
|
||||
//
|
||||
// DeviceIntegrityAvailable reports no support. The APK handler always reports
|
||||
// support, so this answer differs from Android: it tells Roblox's challenge
|
||||
// flow up front that no token can come, instead of letting it request one.
|
||||
//
|
||||
// GetIntegrityToken returns at once with an empty token and
|
||||
// TOKEN_PROVIDER_UNINITIALIZED, the APK's answer when its Play token provider
|
||||
// never initialized. The APK answers INVALID_NONCE for a request without a
|
||||
// requestHash; this bridge never inspects the request, so it cannot.
|
||||
// LuaApp bounds each token request with its
|
||||
// DeviceIntegrityNativeTimeoutMilliseconds flag; an immediate answer keeps a
|
||||
// login challenge from sitting out that bound.
|
||||
class RobloxAccountBridge final {
|
||||
public:
|
||||
RobloxAccountBridge(JniEnvironmentProvider environment,
|
||||
RobloxAccountProtocolSymbols symbols,
|
||||
RobloxAccountProtocolObjects objects);
|
||||
~RobloxAccountBridge();
|
||||
RobloxAccountBridge(const RobloxAccountBridge&) = delete;
|
||||
RobloxAccountBridge& operator=(const RobloxAccountBridge&) = delete;
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxAccountProtocolSymbols symbols_;
|
||||
const RobloxAccountProtocolObjects objects_;
|
||||
// Serializes lifecycle only. Native dispatch never holds this mutex.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
@@ -17,6 +18,7 @@
|
||||
#include "runtime/roblox_experience_launch_bridge.h"
|
||||
#include "runtime/roblox_experience_presence.h"
|
||||
#include "runtime/roblox_fresh_game_launch_controller.h"
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
#include "runtime/webview_roblox_cookie.h"
|
||||
@@ -120,7 +122,8 @@ class RobloxExperienceComposition final {
|
||||
RobloxExperienceSurfaceProvider surface_provider = {},
|
||||
RobloxExperiencePresenceObserver presence_observer = {},
|
||||
bool clear_persisted_web_view_cookie = false,
|
||||
bool microphone_enabled = false);
|
||||
bool microphone_enabled = false,
|
||||
RobloxAccountProtocolSymbols account_symbols = {});
|
||||
~RobloxExperienceComposition();
|
||||
|
||||
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
|
||||
@@ -162,6 +165,7 @@ class RobloxExperienceComposition final {
|
||||
bool visible = true;
|
||||
bool back_navigation_disabled = false;
|
||||
bool show_domain_as_title = false;
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
static Status DispatchLaunch(void* context,
|
||||
@@ -201,8 +205,11 @@ class RobloxExperienceComposition final {
|
||||
const WebSurfacePresentation& presentation);
|
||||
// WebViewProtocol and BrowserService are two logical transports over the
|
||||
// APK's single physical WebView container. Either protocol may close or
|
||||
// mutate the surface that the other one opened.
|
||||
Status CloseWebSurface();
|
||||
// mutate the surface that the other one opened. A nonzero
|
||||
// expected_logical_generation closes only that surface.
|
||||
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
|
||||
// Closes a solved challenge that Roblox left open past its grace period.
|
||||
Status CloseUnclaimedSolvedChallenge();
|
||||
void HandleWebSurfaceExit(uint64_t process_generation);
|
||||
Status Dispatch(const RobloxExperienceLaunchRequest& request);
|
||||
Status PromoteAuthenticatedSession();
|
||||
@@ -229,6 +236,7 @@ class RobloxExperienceComposition final {
|
||||
const RobloxWebViewMessageBusSymbols web_view_symbols_;
|
||||
const RobloxBrowserServiceSymbols browser_service_symbols_;
|
||||
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
|
||||
const RobloxAccountProtocolSymbols account_symbols_;
|
||||
const bool microphone_enabled_;
|
||||
const RobloxGameSessionSymbols game_symbols_;
|
||||
const RobloxExperienceJniFactory jni_factory_;
|
||||
@@ -248,6 +256,8 @@ class RobloxExperienceComposition final {
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
|
||||
std::shared_ptr<LifecycleTarget> lifecycle_target_;
|
||||
@@ -257,6 +267,13 @@ class RobloxExperienceComposition final {
|
||||
uint64_t web_surface_logical_generation_ = 0;
|
||||
uint64_t next_web_surface_process_generation_ = 1;
|
||||
uint64_t next_web_surface_logical_generation_ = 1;
|
||||
// The current surface closes as soon as its solved challenge is forwarded.
|
||||
bool web_surface_close_when_challenge_solved_ = false;
|
||||
// Set when a solved challenge is forwarded; applies only to that logical
|
||||
// surface generation.
|
||||
std::optional<std::chrono::steady_clock::time_point>
|
||||
solved_challenge_close_deadline_;
|
||||
uint64_t solved_challenge_logical_generation_ = 0;
|
||||
SecureWebViewRobloxCookie web_view_cookie_;
|
||||
std::string web_view_cookie_initialization_error_;
|
||||
bool web_view_cookie_synchronized_ = false;
|
||||
|
||||
@@ -40,9 +40,10 @@ struct AndroidKeyMapping {
|
||||
// through with an ABI-incompatible numeric value.
|
||||
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
|
||||
|
||||
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
|
||||
// numbers are not numerically compatible with Android button bit values.
|
||||
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
|
||||
// Translates SDL button numbers to the nativePassMouseButton argument.
|
||||
// The first three are Roblox's zero-based MouseButton1/2/3 indices, not Android
|
||||
// MotionEvent button bits.
|
||||
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button);
|
||||
|
||||
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
|
||||
int32_t RobloxGamepadType(platform::GamepadFamily family);
|
||||
|
||||
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
|
||||
Status ParseRobloxLaunchUri(std::string_view uri,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
// Converts a WebView launchGame command
|
||||
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
|
||||
// contract. Returns kUnsupported for other commands and kInvalidArgument for
|
||||
// a malformed launchGame request.
|
||||
Status ParseRobloxWebViewLaunchGame(std::string_view command,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
|
||||
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
@@ -15,6 +16,8 @@ struct RobloxPlatformWebSymbols {
|
||||
RobloxWebViewMessageBusSymbols web_view;
|
||||
RobloxBrowserServiceSymbols browser_service;
|
||||
RobloxPermissionsMessageBusSymbols permissions;
|
||||
// Optional and excluded from complete(); see RobloxExperienceComposition.
|
||||
RobloxAccountProtocolSymbols account;
|
||||
|
||||
bool complete() const {
|
||||
return web_view.complete() && browser_service.complete() &&
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
@@ -42,6 +44,10 @@ struct RobloxWebViewOpenRequest {
|
||||
std::optional<bool> show_domain_as_title;
|
||||
std::optional<bool> back_button_visible;
|
||||
std::optional<bool> hide_header;
|
||||
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
|
||||
// window once its solved captcha has been forwarded, as Android's captcha
|
||||
// activity finishes itself on success.
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
|
||||
@@ -61,6 +67,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
|
||||
RobloxWebViewMutationRequest* request);
|
||||
Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
// True for a page's Navigation.navigateToFeature command reporting a solved
|
||||
// verification (see webview::IsRobloxChallengeSolvedFeature).
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
|
||||
@@ -108,6 +117,33 @@ using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
||||
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
||||
using UpdateRobloxCookieSetHandlerFn =
|
||||
void (*)(JNIEnv*, jobject, jobject);
|
||||
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
||||
// and its handleJavascriptCallback subscriber, exist only while a handle is
|
||||
// held. Roblox's handle type is not trivially destructible, so its getter
|
||||
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
|
||||
// AArch64). The user-provided destructor gives this type the same calling
|
||||
// convention. It does not release the handle.
|
||||
class RobloxWebViewProtocolHandle final {
|
||||
public:
|
||||
RobloxWebViewProtocolHandle() = default;
|
||||
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
|
||||
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
|
||||
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
|
||||
default;
|
||||
~RobloxWebViewProtocolHandle() {}
|
||||
|
||||
void* holder() const { return holder_; }
|
||||
|
||||
private:
|
||||
void* holder_ = nullptr;
|
||||
};
|
||||
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
|
||||
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
|
||||
// resolver finds it through the first call in
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol.
|
||||
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
|
||||
|
||||
struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
|
||||
@@ -129,6 +165,8 @@ struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
|
||||
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
|
||||
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
||||
// Optional: found by code contract, not by symbol name.
|
||||
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_open_window_id != nullptr &&
|
||||
@@ -287,6 +325,10 @@ private:
|
||||
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
|
||||
raw_callback_targets_{};
|
||||
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
|
||||
// The reference this counts is kept for the process lifetime, as Roblox's
|
||||
// Android app start keeps its own. Neither Shutdown nor destruction
|
||||
// releases it.
|
||||
RobloxWebViewProtocolHandle web_view_protocol_handle_;
|
||||
std::size_t in_flight_dispatches_ = 0;
|
||||
bool initializing_ = false;
|
||||
bool shutting_down_ = false;
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ActivePayloadPaths {
|
||||
std::filesystem::path root;
|
||||
std::filesystem::path roblox_library;
|
||||
std::filesystem::path base_apk;
|
||||
std::filesystem::path x86_64_split_apk;
|
||||
std::filesystem::path arch_split_apk;
|
||||
std::filesystem::path assets_content;
|
||||
std::filesystem::path compatibility_manifest;
|
||||
std::filesystem::path host_abi_profile;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::webview {
|
||||
|
||||
// Navigation.navigateToFeature features a Roblox verification page sends when
|
||||
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
|
||||
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
|
||||
// helper and the runtime both classify page events with this list.
|
||||
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
|
||||
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
|
||||
feature == "ChallengeCompleted";
|
||||
}
|
||||
|
||||
} // namespace mocktail::webview
|
||||
|
||||
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
@@ -0,0 +1,10 @@
|
||||
# Allowed reduced aarch64 packages from pkgforge-dev/archlinux-pkgs-debloated.
|
||||
# The installer verifies the rolling release asset IDs and SHA256 digests.
|
||||
ffmpeg-mini-aarch64.pkg.tar.zst
|
||||
gdk-pixbuf2-mini-aarch64.pkg.tar.xz
|
||||
gtk4-mini-aarch64.pkg.tar.zst
|
||||
icu-mini-aarch64.pkg.tar.zst
|
||||
librsvg-mini-aarch64.pkg.tar.zst
|
||||
libxml2-mini-aarch64.pkg.tar.zst
|
||||
mesa-mini-aarch64.pkg.tar.zst
|
||||
opus-mini-aarch64.pkg.tar.zst
|
||||
@@ -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>
|
||||
|
||||
@@ -461,6 +461,7 @@ LatestSupportedBootstrapProfile() {
|
||||
.version_code <= ($remote_version_code | tonumber)) and
|
||||
.status == "supported" and .default_allowed == true and
|
||||
.allow_legacy_binary_patches == false and
|
||||
(.abi // "x86_64") == "x86_64" and
|
||||
(.version_name | type) == "string" and
|
||||
(.version_name | length) > 0)] |
|
||||
group_by(.version_code) |
|
||||
@@ -495,6 +496,7 @@ SelectSupportedBootstrapFallback() {
|
||||
bootstrap_source="$(jq -er --argjson version_code "${fallback_version_code}" '
|
||||
[.sources[]? |
|
||||
select(.version_code == $version_code and
|
||||
(.abi // "x86_64") == "x86_64" and
|
||||
.provider == "uptodown")] |
|
||||
if length == 1 then .[0].provider else empty end
|
||||
' "${BOOTSTRAP_SOURCES_PATH}" 2>/dev/null || true)"
|
||||
|
||||
@@ -329,6 +329,7 @@ def run_trial(args, max_ctors, skipped_offsets, trial_id):
|
||||
log_path = args.log_dir / f"trial_{trial_id:03d}_max_{max_ctors}.log"
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"ROBLOX_LIB_PATH": str(args.libroblox),
|
||||
"MOCKTAIL_RUN_LIBROBLOX_CTORS": "1",
|
||||
"MOCKTAIL_LIBROBLOX_CTOR_POLICY": args.ctor_policy,
|
||||
"MOCKTAIL_GRAPHICS_BACKEND": args.graphics_backend,
|
||||
@@ -500,7 +501,7 @@ def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project-root", type=Path, default=project_root)
|
||||
parser.add_argument("--run-script", type=Path,
|
||||
default=project_root / "scripts" / "run_sober.sh")
|
||||
default=project_root / "build" / "mocktail")
|
||||
parser.add_argument("--libroblox", type=Path,
|
||||
default=project_root / "rbx_bin" / "libroblox.so")
|
||||
parser.add_argument("--log-dir", type=Path,
|
||||
|
||||
@@ -10,18 +10,18 @@ readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
|
||||
|
||||
BUILD_DIR="${PROJECT_ROOT}/build-flatpak"
|
||||
MANIFEST="${PROJECT_ROOT}/packaging/flatpak/io.github.CoderDayton.nightcap.json"
|
||||
JOBS="${MOCKTAIL_FLATPAK_JOBS:-4}"
|
||||
JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"
|
||||
|
||||
Usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/build_flatpak.sh [OPTIONS]
|
||||
|
||||
Build and install Mocktail as a per-user x86-64 Flatpak.
|
||||
Build and install Mocktail as a per-user Flatpak for the host architecture.
|
||||
|
||||
Options:
|
||||
--build-dir DIR Builder output directory (default: build-flatpak).
|
||||
--manifest FILE Flatpak manifest path.
|
||||
--jobs N Maximum parallel build jobs (default: 4).
|
||||
--jobs N Maximum parallel build jobs (default: 2).
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
@@ -93,8 +93,11 @@ done
|
||||
[[ "${JOBS}" =~ ^[1-9][0-9]*$ && "${JOBS}" -le 256 ]] ||
|
||||
Die "--jobs must be an integer between 1 and 256"
|
||||
|
||||
[[ "$(uname -m)" == x86_64 ]] ||
|
||||
Die "the Roblox payload and Flatpak manifest currently require x86-64"
|
||||
HOST_ARCH="$(uname -m)"
|
||||
case "${HOST_ARCH}" in
|
||||
x86_64|aarch64) ;;
|
||||
*) Die "Flatpak builds require an x86_64 or aarch64 Linux host" ;;
|
||||
esac
|
||||
command -v flatpak >/dev/null 2>&1 || Die "flatpak is required"
|
||||
|
||||
MANIFEST="$(realpath -e -- "${MANIFEST}")" ||
|
||||
@@ -123,7 +126,7 @@ CleanupStaleBuilderMounts
|
||||
|
||||
cd -- "${PROJECT_ROOT}"
|
||||
exec "${builder[@]}" \
|
||||
--arch=x86_64 \
|
||||
--arch="${HOST_ARCH}" \
|
||||
--jobs="${JOBS}" \
|
||||
--force-clean \
|
||||
--install-deps-from=flathub \
|
||||
|
||||
@@ -61,6 +61,7 @@ def supported_profiles(catalog: dict) -> list[dict]:
|
||||
if isinstance(profile, dict)
|
||||
and profile.get("status") == "supported"
|
||||
and profile.get("default_allowed") is True
|
||||
and profile.get("abi", ARCHITECTURE) == ARCHITECTURE
|
||||
and isinstance(profile.get("version_name"), str)
|
||||
and isinstance(profile.get("version_code"), int)
|
||||
]
|
||||
|
||||
@@ -18,12 +18,15 @@ AnyLinuxDependencyRequireContainer() {
|
||||
}
|
||||
|
||||
AnyLinuxDependencySnapshot() {
|
||||
local manifest="$1" release="$2" filename remainder names_json
|
||||
local manifest="$1" release="$2" host_arch="${3:-$(uname -m)}"
|
||||
local filename remainder names_json
|
||||
[[ "${host_arch}" == x86_64 || "${host_arch}" == aarch64 ]] ||
|
||||
AnyLinuxDependencyDie 'unsupported AppImage architecture'
|
||||
local -a names=()
|
||||
local -A seen=()
|
||||
while read -r filename remainder || [[ -n "${filename}" ]]; do
|
||||
[[ -n "${filename}" && "${filename}" != \#* ]] || continue
|
||||
[[ "${filename}" =~ ^[a-z0-9-]+-mini-x86_64\.pkg\.tar\.zst$ &&
|
||||
[[ "${filename}" =~ ^[a-z0-9-]+-mini-${host_arch}\.pkg\.tar\.(zst|xz)$ &&
|
||||
-z "${remainder}" && -z "${seen[${filename}]:-}" ]] ||
|
||||
AnyLinuxDependencyDie 'invalid or duplicate dependency manifest entry'
|
||||
seen["${filename}"]=1
|
||||
@@ -108,7 +111,13 @@ AnyLinuxDependenciesMain() (
|
||||
AnyLinuxDependencyRequireContainer
|
||||
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
|
||||
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.txt"
|
||||
local host_arch="$(uname -m)" manifest_name
|
||||
case "${host_arch}" in
|
||||
x86_64) manifest_name=anylinux-dependencies.txt ;;
|
||||
aarch64) manifest_name=anylinux-dependencies-aarch64.txt ;;
|
||||
*) AnyLinuxDependencyDie "unsupported AppImage architecture: ${host_arch}" ;;
|
||||
esac
|
||||
readonly MANIFEST="${ROOT}/packaging/${manifest_name}"
|
||||
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
|
||||
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
|
||||
AnyLinuxDownloadDependencies "${MANIFEST}" "${DOWNLOADS}"
|
||||
|
||||
@@ -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,6 +16,8 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "compat/bionic_pthread_create_runtime.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "compat/http_client_spin_guard.h"
|
||||
#include "window/window.h"
|
||||
|
||||
@@ -31,6 +33,14 @@ const char* EnvironmentOr(const char* name, const char* fallback) {
|
||||
return value != nullptr && value[0] != '\0' ? value : fallback;
|
||||
}
|
||||
|
||||
const char* GuestAbiPropertyValue() {
|
||||
#if defined(__aarch64__)
|
||||
return "arm64-v8a";
|
||||
#else
|
||||
return "x86_64";
|
||||
#endif
|
||||
}
|
||||
|
||||
int CallHostVsnprintf(char* dst, size_t count, const char* format,
|
||||
va_list args) {
|
||||
using HostVsnprintf = int (*)(char*, size_t, const char*, va_list);
|
||||
@@ -173,8 +183,12 @@ int __system_property_get(const char* name, char* value) {
|
||||
result = "35";
|
||||
} else if (std::strcmp(name, "ro.build.version.release") == 0) {
|
||||
result = "15";
|
||||
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0) {
|
||||
result = "x86_64";
|
||||
} else if (std::strcmp(name, "ro.product.cpu.abi") == 0 ||
|
||||
std::strcmp(name, "ro.product.cpu.abilist") == 0 ||
|
||||
std::strcmp(name, "ro.product.cpu.abilist64") == 0) {
|
||||
result = GuestAbiPropertyValue();
|
||||
} else if (std::strcmp(name, "ro.product.cpu.abilist32") == 0) {
|
||||
result = "";
|
||||
} else if (std::strcmp(name, "ro.product.manufacturer") == 0) {
|
||||
result = EnvironmentOr("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
|
||||
} else if (std::strcmp(name, "ro.product.model") == 0) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
#include "compat/bionic_pthread_create_runtime.h"
|
||||
|
||||
#include "compat/bionic_abi_exports.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
|
||||
@@ -69,24 +73,28 @@ size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
|
||||
return guest_stack_size;
|
||||
}
|
||||
|
||||
int CopySupportedThreadAttributes(const pthread_attr_t& source,
|
||||
// Guest pthread_attr_t buffers use the Bionic LP64 layout; they are never a
|
||||
// host pthread_attr_t, so decode the fixed fields directly.
|
||||
int GuestDetachState(const MocktailBionicPthreadAttr& source) noexcept {
|
||||
return (source.flags & kMocktailBionicAttrFlagDetached) != 0
|
||||
? PTHREAD_CREATE_DETACHED
|
||||
: PTHREAD_CREATE_JOINABLE;
|
||||
}
|
||||
|
||||
size_t GuestStackSize(const MocktailBionicPthreadAttr& source) noexcept {
|
||||
return source.stack_size != 0 ? source.stack_size
|
||||
: kBionicLp64DefaultThreadStackSize;
|
||||
}
|
||||
|
||||
int CopySupportedThreadAttributes(const MocktailBionicPthreadAttr& source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
int result = pthread_attr_getdetachstate(&source, &detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setdetachstate(destination, detach_state);
|
||||
int result =
|
||||
pthread_attr_setdetachstate(destination, GuestDetachState(source));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
size_t guard_size = 0;
|
||||
result = pthread_attr_getguardsize(&source, &guard_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setguardsize(destination, guard_size);
|
||||
result = pthread_attr_setguardsize(destination, source.guard_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -95,53 +103,38 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
|
||||
// pthread_attr_setstack. Copying pthread_attr_getstack's address is unsafe:
|
||||
// glibc represents an automatically allocated stack with a synthetic
|
||||
// address derived from its size.
|
||||
size_t stack_size = 0;
|
||||
result = pthread_attr_getstacksize(&source, &stack_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setstacksize(destination,
|
||||
HostStackSizeForGuest(stack_size));
|
||||
result = pthread_attr_setstacksize(
|
||||
destination, HostStackSizeForGuest(GuestStackSize(source)));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int inherit_scheduler = PTHREAD_INHERIT_SCHED;
|
||||
result = pthread_attr_getinheritsched(&source, &inherit_scheduler);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
// Bionic applies the attribute's scheduling policy at creation whenever it
|
||||
// differs from the normal policy.
|
||||
if (RejectRealtimeScheduling(source.sched_policy)) {
|
||||
return EPERM;
|
||||
}
|
||||
if (inherit_scheduler == PTHREAD_EXPLICIT_SCHED) {
|
||||
int scheduler_policy = 0;
|
||||
sched_param scheduler_parameters{};
|
||||
result = pthread_attr_getschedpolicy(&source, &scheduler_policy);
|
||||
if (result == 0) {
|
||||
result = pthread_attr_getschedparam(&source, &scheduler_parameters);
|
||||
}
|
||||
if (result == 0 && RejectRealtimeScheduling(scheduler_policy)) {
|
||||
return EPERM;
|
||||
}
|
||||
if (result == 0) {
|
||||
result = pthread_attr_setschedpolicy(destination, scheduler_policy);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
|
||||
}
|
||||
if (source.sched_policy != SCHED_NORMAL) {
|
||||
result = pthread_attr_setschedpolicy(destination, source.sched_policy);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
sched_param scheduler_parameters{};
|
||||
scheduler_parameters.sched_priority = source.sched_priority;
|
||||
result = pthread_attr_setschedparam(destination, &scheduler_parameters);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
return pthread_attr_setinheritsched(destination, PTHREAD_EXPLICIT_SCHED);
|
||||
}
|
||||
return pthread_attr_setinheritsched(destination, inherit_scheduler);
|
||||
|
||||
return pthread_attr_setinheritsched(destination, PTHREAD_INHERIT_SCHED);
|
||||
}
|
||||
|
||||
int CopyRequiredThreadAttributes(const pthread_attr_t& source,
|
||||
int CopyRequiredThreadAttributes(const MocktailBionicPthreadAttr& source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
int result = pthread_attr_getdetachstate(&source, &detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setdetachstate(destination, detach_state);
|
||||
int result =
|
||||
pthread_attr_setdetachstate(destination, GuestDetachState(source));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -149,32 +142,20 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source,
|
||||
// The retry must retain an explicitly requested guest stack. Falling back
|
||||
// to a null/default host attr is unsafe on musl, whose default stack is much
|
||||
// smaller than Bionic's and can be exhausted by a single libroblox frame.
|
||||
size_t stack_size = 0;
|
||||
result = pthread_attr_getstacksize(&source, &stack_size);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
return pthread_attr_setstacksize(destination,
|
||||
HostStackSizeForGuest(stack_size));
|
||||
return pthread_attr_setstacksize(
|
||||
destination, HostStackSizeForGuest(GuestStackSize(source)));
|
||||
}
|
||||
|
||||
int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
|
||||
int ConfigureHostSafeStackFallback(const MocktailBionicPthreadAttr* source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
size_t guest_stack_size = 0;
|
||||
if (source != nullptr) {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
int result = pthread_attr_getdetachstate(source, &detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setdetachstate(destination, detach_state);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_getstacksize(source, &guest_stack_size);
|
||||
int result =
|
||||
pthread_attr_setdetachstate(destination, GuestDetachState(*source));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
guest_stack_size = GuestStackSize(*source);
|
||||
}
|
||||
|
||||
size_t host_default_stack_size = 0;
|
||||
@@ -207,7 +188,8 @@ void ConfigureBionicPthreadThreadInitializer(
|
||||
g_thread_initializer.store(initializer, std::memory_order_release);
|
||||
}
|
||||
|
||||
int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
|
||||
int CreateBionicPthread(pthread_t* thread,
|
||||
const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument) {
|
||||
if (thread == nullptr || start_routine == nullptr) {
|
||||
return EINVAL;
|
||||
@@ -289,10 +271,124 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
|
||||
const pthread_attr_t* attr,
|
||||
void* (*start_routine)(void*),
|
||||
void* argument) {
|
||||
extern "C" {
|
||||
|
||||
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr) {
|
||||
if (attr == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
attr->flags = 0;
|
||||
attr->stack_base = nullptr;
|
||||
attr->stack_size =
|
||||
mocktail::compat::kBionicLp64DefaultThreadStackSize;
|
||||
const long page_size = sysconf(_SC_PAGESIZE);
|
||||
attr->guard_size = page_size > 0 ? static_cast<size_t>(page_size) : 0;
|
||||
attr->sched_policy = SCHED_OTHER;
|
||||
attr->sched_priority = 0;
|
||||
std::memset(attr->reserved, 0, sizeof(attr->reserved));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr) {
|
||||
(void)attr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
|
||||
size_t stack_size) {
|
||||
if (attr == nullptr || stack_size < PTHREAD_STACK_MIN) {
|
||||
return EINVAL;
|
||||
}
|
||||
attr->stack_size = stack_size;
|
||||
attr->flags &= ~kMocktailBionicAttrFlagUserStack;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
|
||||
int detach_state) {
|
||||
if (attr == nullptr || (detach_state != PTHREAD_CREATE_DETACHED &&
|
||||
detach_state != PTHREAD_CREATE_JOINABLE)) {
|
||||
return EINVAL;
|
||||
}
|
||||
if (detach_state == PTHREAD_CREATE_DETACHED) {
|
||||
attr->flags |= kMocktailBionicAttrFlagDetached;
|
||||
} else {
|
||||
attr->flags &= ~kMocktailBionicAttrFlagDetached;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
|
||||
const struct sched_param* parameters) {
|
||||
if (attr == nullptr || parameters == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
attr->sched_priority = parameters->sched_priority;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_getattr_np(pthread_t thread,
|
||||
MocktailBionicPthreadAttr* attr) {
|
||||
if (attr == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
pthread_attr_t host_attr;
|
||||
int result = pthread_getattr_np(thread, &host_attr);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
void* stack_base = nullptr;
|
||||
size_t stack_size = 0;
|
||||
result = pthread_attr_getstack(&host_attr, &stack_base, &stack_size);
|
||||
size_t guard_size = 0;
|
||||
if (result == 0) {
|
||||
result = pthread_attr_getguardsize(&host_attr, &guard_size);
|
||||
}
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
if (result == 0) {
|
||||
result = pthread_attr_getdetachstate(&host_attr, &detach_state);
|
||||
}
|
||||
sched_param parameters{};
|
||||
int policy = SCHED_OTHER;
|
||||
if (result == 0) {
|
||||
result = pthread_getschedparam(thread, &policy, ¶meters);
|
||||
}
|
||||
pthread_attr_destroy(&host_attr);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
attr->flags = detach_state == PTHREAD_CREATE_DETACHED
|
||||
? kMocktailBionicAttrFlagDetached
|
||||
: 0;
|
||||
attr->stack_base = stack_base;
|
||||
attr->stack_size = stack_size;
|
||||
attr->guard_size = guard_size;
|
||||
attr->sched_policy = policy;
|
||||
attr->sched_priority = parameters.sched_priority;
|
||||
std::memset(attr->reserved, 0, sizeof(attr->reserved));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
|
||||
void** stack_base, size_t* stack_size) {
|
||||
if (attr == nullptr) {
|
||||
return EINVAL;
|
||||
}
|
||||
if (stack_base != nullptr) {
|
||||
*stack_base = attr->stack_base;
|
||||
}
|
||||
if (stack_size != nullptr) {
|
||||
*stack_size = attr->stack_size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
} // extern "C"
|
||||
|
||||
extern "C" int mocktail_bionic_pthread_create(
|
||||
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
|
||||
void* (*start_routine)(void*), void* argument) {
|
||||
return mocktail::compat::CreateBionicPthread(thread, attr, start_routine,
|
||||
argument);
|
||||
}
|
||||
|
||||
@@ -39,12 +39,23 @@ bool PatchAbsoluteJump(uintptr_t address, void *target) noexcept {
|
||||
return false;
|
||||
}
|
||||
|
||||
#if defined(__aarch64__)
|
||||
// ldr x16, #8 ; br x16 ; .quad target
|
||||
// x16 is the intra-procedure-call scratch register, free to clobber.
|
||||
unsigned char patch[] = {
|
||||
0x50, 0x00, 0x00, 0x58, 0x00, 0x02, 0x1f, 0xd6,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
|
||||
std::memcpy(patch + 8, &target_address, sizeof(target_address));
|
||||
#else
|
||||
unsigned char patch[] = {
|
||||
0x48, 0xb8, // movabs rax, imm64
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xe0, // jmp rax
|
||||
};
|
||||
const uintptr_t target_address = reinterpret_cast<uintptr_t>(target);
|
||||
std::memcpy(patch + 2, &target_address, sizeof(target_address));
|
||||
#endif
|
||||
|
||||
const uintptr_t page_mask = static_cast<uintptr_t>(page_size) - 1;
|
||||
const uintptr_t page = address & ~page_mask;
|
||||
|
||||
@@ -6,6 +6,34 @@ namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
constexpr HostAbiProfile kProfiles[] = {
|
||||
// Roblox 2.736.1408 arm64-v8a. Every RVA was derived from the AArch64
|
||||
// payload of the same release as the x86-64 reference above, matched by
|
||||
// allocator internals, init_array call graphs, and static state layout.
|
||||
// Constructor policy mirrors x86-64 by semantic role: the pre-init
|
||||
// entries 0..3 stay skipped, index 4 is mi_process_load (thread init
|
||||
// checkpoint), index 5 is allocator wiring and index 6 is the lazy clock
|
||||
// singleton getter, both excluded under host bridges only.
|
||||
{"ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
|
||||
{{{0x1d53840, HostBridgeKind::kAllocate, "small-allocate"},
|
||||
{0x1d56c34, HostBridgeKind::kUsableSize, "usable-size"},
|
||||
{0x1d6ef80, HostBridgeKind::kReallocate, "reallocate"},
|
||||
{0x1d53568, HostBridgeKind::kAllocate, "allocate"},
|
||||
{0x61d3850, HostBridgeKind::kAlignedAllocate,
|
||||
"aligned-allocate-direct"},
|
||||
{0x1d57328, HostBridgeKind::kFree, "free"}}},
|
||||
6,
|
||||
{0, 0, 0x7296c88, 0x400, 0x1d53c54, 0x1dc76a0,
|
||||
0x71d6c90, 0x71d2c40, 0x400000},
|
||||
{0x1d53568, 0x1d57328},
|
||||
0x6733128,
|
||||
3565,
|
||||
{{{4, 5}, {7, 3565}}},
|
||||
2,
|
||||
{{{4, 3565}}},
|
||||
1,
|
||||
4,
|
||||
{0x2132c5c, 0x71d6d30},
|
||||
HostAllocatorStrategy::kNativeMimalloc},
|
||||
// Roblox 2.736.1408. Derived from the verified 2908 profile and validated
|
||||
// by two isolated no-recovery Vulkan canaries and real game sessions.
|
||||
{"ade08266c67aee88ec9c1d00902150e1684dad3a",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::compat {
|
||||
namespace {
|
||||
@@ -611,19 +612,26 @@ std::unique_ptr<OwnedHostAbiProfile> ParseProfile(const Json& root,
|
||||
*error = "external host ABI profile has inconsistent constructor policy";
|
||||
return nullptr;
|
||||
}
|
||||
if (owned->profile.init_array_count <= 5 ||
|
||||
// The verified constructor layout is the same on both guest ABIs: a single
|
||||
// process-load constructor at the thread-initializer checkpoint, two wiring
|
||||
// entries excluded under host bridges, and one contiguous native-mimalloc
|
||||
// replay from the checkpoint to the end of .init_array. Only the checkpoint
|
||||
// index itself differs between payloads.
|
||||
const std::size_t checkpoint =
|
||||
owned->profile.native_mimalloc_thread_initializer_after_constructor;
|
||||
if (owned->profile.init_array_count <= checkpoint + 3 ||
|
||||
owned->profile.constructor_run_range_count != 2 ||
|
||||
owned->profile.constructor_run_ranges[0].begin != 2 ||
|
||||
owned->profile.constructor_run_ranges[0].end_exclusive != 3 ||
|
||||
owned->profile.constructor_run_ranges[1].begin != 5 ||
|
||||
owned->profile.constructor_run_ranges[0].begin != checkpoint ||
|
||||
owned->profile.constructor_run_ranges[0].end_exclusive != checkpoint + 1 ||
|
||||
owned->profile.constructor_run_ranges[1].begin != checkpoint + 3 ||
|
||||
owned->profile.constructor_run_ranges[1].end_exclusive !=
|
||||
owned->profile.init_array_count ||
|
||||
owned->profile.native_mimalloc_constructor_run_range_count != 1 ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin != 2 ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].begin !=
|
||||
checkpoint ||
|
||||
owned->profile.native_mimalloc_constructor_run_ranges[0].end_exclusive !=
|
||||
owned->profile.init_array_count ||
|
||||
owned->profile.native_mimalloc_thread_initializer_after_constructor !=
|
||||
2) {
|
||||
(kGuestElfMachine == EM_X86_64 && checkpoint != 2)) {
|
||||
*error =
|
||||
"external host ABI profile changed the verified constructor policy";
|
||||
return nullptr;
|
||||
@@ -669,9 +677,10 @@ bool ValidateProfileAgainstElf(const std::string& payload_path,
|
||||
}
|
||||
GElf_Ehdr header{};
|
||||
if (gelf_getehdr(elf.get(), &header) == nullptr ||
|
||||
gelf_getclass(elf.get()) != ELFCLASS64 || header.e_machine != EM_X86_64 ||
|
||||
header.e_type != ET_DYN) {
|
||||
*error = "payload must be an x86-64 ET_DYN ELF object";
|
||||
gelf_getclass(elf.get()) != ELFCLASS64 ||
|
||||
header.e_machine != kGuestElfMachine || header.e_type != ET_DYN) {
|
||||
*error = "payload must be a " + std::string(kGuestAbi) +
|
||||
" ET_DYN ELF object";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Android Vulkan loader ABI -> host Vulkan loader + SDL3 WSI.
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -1650,9 +1650,13 @@ jlong ParseLocalStorageUserIdFromEnv() {
|
||||
if (!value || value[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
errno = 0;
|
||||
char* end = nullptr;
|
||||
long long parsed = std::strtoll(value, &end, 10);
|
||||
return end == value ? 0 : static_cast<jlong>(parsed);
|
||||
if (errno == ERANGE || end == value || *end != '\0') {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<jlong>(parsed);
|
||||
}
|
||||
|
||||
jlong CurrentLocalStorageUserLocked() {
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/prctl.h>
|
||||
#if defined(__x86_64__)
|
||||
#include <asm/prctl.h>
|
||||
#endif
|
||||
#include <sys/syscall.h>
|
||||
#include <vector>
|
||||
|
||||
@@ -50,6 +52,7 @@
|
||||
#include "compat/bionic_socket_runtime.h"
|
||||
#include "compat/build_profile.h"
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "compat/host_abi_experiment.h"
|
||||
#include "compat/host_abi_profile.h"
|
||||
#include "compat/host_allocator_bridge.h"
|
||||
@@ -1046,6 +1049,15 @@ void PumpRobloxMainThreadMessagesOnce() {
|
||||
mocktail::graphics::ChromeTraceWriter* trace =
|
||||
hooks.active != nullptr ? hooks.active() : nullptr;
|
||||
const std::uint64_t pump_start_ns = trace != nullptr ? hooks.clock() : 0;
|
||||
// CPU time separates a step that computes from one that waits.
|
||||
const auto thread_cpu_ns = [] {
|
||||
timespec now{};
|
||||
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now);
|
||||
return static_cast<std::uint64_t>(now.tv_sec) * 1'000'000'000ULL +
|
||||
static_cast<std::uint64_t>(now.tv_nsec);
|
||||
};
|
||||
const std::uint64_t pump_start_cpu_ns =
|
||||
trace != nullptr ? thread_cpu_ns() : 0;
|
||||
g_native_call_messages_from_main_thread(
|
||||
env, g_native_gl_class_for_main_thread);
|
||||
if (trace != nullptr) {
|
||||
@@ -1053,8 +1065,11 @@ void PumpRobloxMainThreadMessagesOnce() {
|
||||
// calls that ran a message are recorded.
|
||||
const std::uint64_t pump_end_ns = hooks.clock();
|
||||
if (pump_end_ns - pump_start_ns >= 20'000) {
|
||||
const mocktail::graphics::TraceArg args[] = {
|
||||
{"cpu_us", static_cast<std::int64_t>(
|
||||
(thread_cpu_ns() - pump_start_cpu_ns) / 1000)}};
|
||||
hooks.slice(trace, "nativeCallMessagesFromMainThread", "pump",
|
||||
pump_start_ns, pump_end_ns, nullptr, 0);
|
||||
pump_start_ns, pump_end_ns, args, 1);
|
||||
}
|
||||
}
|
||||
if (__builtin_expect(trace_pump, 0)) {
|
||||
@@ -1096,7 +1111,7 @@ struct BionicAddrInfo {
|
||||
};
|
||||
|
||||
static_assert(sizeof(BionicAddrInfo) == 48,
|
||||
"unexpected x86_64 bionic addrinfo size");
|
||||
"unexpected bionic addrinfo size");
|
||||
|
||||
bool DnsTraceEnabled() {
|
||||
return IsEnabled("MOCKTAIL_DNS_TRACE");
|
||||
@@ -1461,15 +1476,31 @@ bool IsUnsafeSoftTimeoutModule(void* rip) {
|
||||
void JniOnLoadTimeoutAlarm(int, siginfo_t* info, void* context) {
|
||||
static_cast<void>(info);
|
||||
auto* uc = static_cast<ucontext_t*>(context);
|
||||
#if defined(__x86_64__)
|
||||
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RIP]) : 0;
|
||||
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RSP]) : 0;
|
||||
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RBP]) : 0;
|
||||
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.gregs[REG_RAX]) : 0;
|
||||
const char* regs_format =
|
||||
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
|
||||
"RAX=0x%016llx\n";
|
||||
#elif defined(__aarch64__)
|
||||
auto rip = uc ? static_cast<uintptr_t>(uc->uc_mcontext.pc) : 0;
|
||||
auto rsp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.sp) : 0;
|
||||
auto rbp = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[29]) : 0;
|
||||
auto rax = uc ? static_cast<uintptr_t>(uc->uc_mcontext.regs[0]) : 0;
|
||||
const char* regs_format =
|
||||
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
|
||||
"X0=0x%016llx\n";
|
||||
#else
|
||||
uintptr_t rip = 0, rsp = 0, rbp = 0, rax = 0;
|
||||
const char* regs_format =
|
||||
" [timeout] PC=0x%016llx SP=0x%016llx FP=0x%016llx "
|
||||
"X0=0x%016llx\n";
|
||||
#endif
|
||||
char regs_msg[192];
|
||||
int len = std::snprintf(
|
||||
regs_msg, sizeof(regs_msg),
|
||||
" [timeout] RIP=0x%016llx RSP=0x%016llx RBP=0x%016llx "
|
||||
"RAX=0x%016llx\n",
|
||||
regs_msg, sizeof(regs_msg), regs_format,
|
||||
static_cast<unsigned long long>(rip), static_cast<unsigned long long>(rsp),
|
||||
static_cast<unsigned long long>(rbp), static_cast<unsigned long long>(rax));
|
||||
write(2, regs_msg, static_cast<size_t>(len));
|
||||
@@ -2517,10 +2548,12 @@ jobject BuildDeviceParams(JNIEnv* env) {
|
||||
GetEnvString("MOCKTAIL_DEVICE_NAME", "Mocktail Linux");
|
||||
const std::string manufacturer =
|
||||
GetEnvString("MOCKTAIL_DEVICE_MANUFACTURER", "Mocktail");
|
||||
const std::string device_sku =
|
||||
GetEnvString("MOCKTAIL_DEVICE_SKU", "mocktail-x86_64");
|
||||
const std::string device_sku = GetEnvString(
|
||||
"MOCKTAIL_DEVICE_SKU",
|
||||
("mocktail-" + std::string(mocktail::compat::kGuestCpuName)).c_str());
|
||||
const std::string soc_model =
|
||||
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL", "x86_64");
|
||||
GetEnvString("MOCKTAIL_DEVICE_SOC_MODEL",
|
||||
std::string(mocktail::compat::kGuestCpuName).c_str());
|
||||
SetStringField(env, params, "osVersion", "33");
|
||||
SetStringField(env, params, "deviceName", device_name.c_str());
|
||||
const std::string app_version =
|
||||
@@ -4718,12 +4751,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
|
||||
// Roblox internal threads trigger SI_KERNEL traps from CET shadow-stack
|
||||
// return mismatches. Unsupported kernels ignore this request.
|
||||
#if defined(__x86_64__)
|
||||
{
|
||||
long r = syscall(SYS_arch_prctl, ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK);
|
||||
if (r == 0) {
|
||||
std::cout << " [cet] shadow-stack (SHSTK) disabled\n";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (build_profile.allow_legacy_binary_patches) {
|
||||
std::cerr
|
||||
@@ -5643,6 +5678,27 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
reinterpret_cast<void*>(mocktail_pthread_spin_trylock));
|
||||
linker::RegisterSymbol("pthread_spin_unlock",
|
||||
reinterpret_cast<void*>(mocktail_pthread_spin_unlock));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_init",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_init));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_destroy",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_destroy));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_setstacksize",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_setstacksize));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_setdetachstate",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_setdetachstate));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_setschedparam",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_setschedparam));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_getattr_np",
|
||||
reinterpret_cast<void*>(mocktail_pthread_getattr_np));
|
||||
linker::RegisterSymbol(
|
||||
"pthread_attr_getstack",
|
||||
reinterpret_cast<void*>(mocktail_pthread_attr_getstack));
|
||||
linker::RegisterSymbol("pthread_barrier_init",
|
||||
reinterpret_cast<void*>(mocktail_pthread_barrier_init));
|
||||
linker::RegisterSymbol(
|
||||
@@ -5762,7 +5818,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
|
||||
if (roblox_handle == nullptr) {
|
||||
std::cerr << "\n[FATAL] Could not load '" << library_path << "'.\n"
|
||||
<< " Extract lib/x86_64/libroblox.so from a Roblox APK and\n"
|
||||
<< " Extract lib/" << compat::kGuestAbi
|
||||
<< "/libroblox.so from a Roblox APK and\n"
|
||||
<< " place it at the path above (or set ROBLOX_LIB_PATH).\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
@@ -6665,7 +6722,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
mocktail::runtime::RobloxExperienceSurfaceProvider{},
|
||||
discord_rpc.observer(),
|
||||
dependencies.clear_persisted_web_view_cookie(),
|
||||
runtime_config.microphone_enabled());
|
||||
runtime_config.microphone_enabled(),
|
||||
platform_web_symbols.account);
|
||||
const mocktail::Status platform_protocol_status =
|
||||
experience_composition->InitializePlatformProtocols();
|
||||
if (!platform_protocol_status.ok()) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
#include "runtime/roblox_launch_uri.h"
|
||||
|
||||
namespace mocktail {
|
||||
@@ -262,11 +263,12 @@ std::string CommandLineUsage(const std::string& program_name) {
|
||||
usage
|
||||
<< "Usage: " << (program_name.empty() ? "mocktail" : program_name)
|
||||
<< " [options] [roblox-uri]\n\n"
|
||||
<< "Normal startup downloads and activates a verified managed x86_64 "
|
||||
"Roblox payload automatically.\n\n"
|
||||
<< "Normal startup downloads and activates a verified managed "
|
||||
<< compat::kGuestAbi
|
||||
<< " Roblox payload automatically.\n\n"
|
||||
<< "Options:\n"
|
||||
<< " --roblox-lib <path> Development override for a specific "
|
||||
"x86_64 libroblox.so\n"
|
||||
<< compat::kGuestAbi << " libroblox.so\n"
|
||||
<< " --headless Run without creating an SDL window\n"
|
||||
<< " --windowed Force windowed startup (default)\n"
|
||||
<< " --graphics <backend> direct-vulkan | opengl | system | "
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "runtime/engine_pump_rest.h"
|
||||
|
||||
#include <cpuid.h>
|
||||
#include <immintrin.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <time.h>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
#include <cpuid.h>
|
||||
#include <immintrin.h>
|
||||
#include <x86intrin.h>
|
||||
#endif
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -23,11 +26,20 @@ uint64_t NowNs() {
|
||||
// The kernel caps one TPAUSE at umwait_control/max_time TSC cycles (100000 by
|
||||
// default), so the wait is a loop of short pauses up to the deadline. Control
|
||||
// bit 0 clear selects C0.2, the deeper of the two light states.
|
||||
#if defined(__x86_64__)
|
||||
__attribute__((target("waitpkg"))) void TpauseUntil(uint64_t deadline_ns) {
|
||||
while (NowNs() < deadline_ns) {
|
||||
_tpause(0, __rdtsc() + 100000ULL);
|
||||
}
|
||||
}
|
||||
#else
|
||||
// TPAUSE is x86-only. CpuHasWaitPkg() is false on other hosts, so kTpause is
|
||||
// never chosen there; this waits out the deadline if it is forced anyway.
|
||||
void TpauseUntil(uint64_t deadline_ns) {
|
||||
while (NowNs() < deadline_ns) {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void LowerTimerSlackOnce() {
|
||||
// Only the main thread rests, so the slack is set once per process.
|
||||
@@ -88,11 +100,15 @@ bool CpuGovernorIsPerformance(const char* path) {
|
||||
}
|
||||
|
||||
bool CpuHasWaitPkg() {
|
||||
#if defined(__x86_64__)
|
||||
unsigned eax = 0, ebx = 0, ecx = 0, edx = 0;
|
||||
if (__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx) == 0) {
|
||||
return false;
|
||||
}
|
||||
return (ecx & (1u << 5)) != 0;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
EnginePumpRestMode ActiveEnginePumpRestMode() {
|
||||
|
||||
@@ -29,12 +29,21 @@ constexpr const char* kIcdDirectories[] = {
|
||||
"/usr/local/share/vulkan/icd.d",
|
||||
};
|
||||
|
||||
#if defined(__aarch64__)
|
||||
constexpr char kNativeArchitecture[] = "aarch64";
|
||||
|
||||
constexpr const char* kForeignArchitectures[] = {
|
||||
"i686", "i586", "i486", "i386", "x86_64", "x86.",
|
||||
"amd64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
|
||||
};
|
||||
#else
|
||||
constexpr char kNativeArchitecture[] = "x86_64";
|
||||
|
||||
constexpr const char* kForeignArchitectures[] = {
|
||||
"i686", "i586", "i486", "i386", "x86.", "aarch64",
|
||||
"arm64", "armhf", "armv7", "ppc64", "riscv64", "s390x",
|
||||
};
|
||||
#endif
|
||||
|
||||
struct HostGpus {
|
||||
bool intel = false;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
|
||||
#include <array>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <iterator>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
constexpr char kAccountProtocolClass[] =
|
||||
"com/roblox/universalapp/account/JNIAccountProtocol";
|
||||
// Serialized name of the APK status enum for a missing Play token provider.
|
||||
constexpr char kTokenProviderUninitialized[] = "TOKEN_PROVIDER_UNINITIALIZED";
|
||||
constexpr std::size_t kMaximumNameBytes = 128;
|
||||
|
||||
enum Method : std::size_t { kDeviceIntegrityAvailable, kGetIntegrityToken };
|
||||
constexpr std::size_t kMethodCount = 2;
|
||||
constexpr const char* kMethodLabels[kMethodCount] = {"DeviceIntegrityAvailable",
|
||||
"GetIntegrityToken"};
|
||||
|
||||
Status Unavailable(const char* message) {
|
||||
return Status::Error(StatusCode::kUnavailable, message);
|
||||
}
|
||||
|
||||
Status CheckJni(JNIEnv* env) {
|
||||
if (!env->ExceptionCheck()) return Status::Ok();
|
||||
env->ExceptionClear();
|
||||
return Unavailable("AccountProtocol JNI operation failed");
|
||||
}
|
||||
|
||||
// Copies a non-empty, bounded engine-provided name.
|
||||
bool ReadName(JNIEnv* env, jstring value, std::string* name) {
|
||||
if (!value) return false;
|
||||
const jsize size = env->GetStringUTFLength(value);
|
||||
const char* bytes = env->GetStringUTFChars(value, nullptr);
|
||||
const bool valid = bytes && size > 0 &&
|
||||
static_cast<std::size_t>(size) <= kMaximumNameBytes;
|
||||
if (valid) name->assign(bytes, static_cast<std::size_t>(size));
|
||||
if (bytes) env->ReleaseStringUTFChars(value, bytes);
|
||||
return valid;
|
||||
}
|
||||
|
||||
// Pins a valid engine-provided name as a global reference.
|
||||
jstring RetainName(JNIEnv* env, jstring value) {
|
||||
std::string ignored;
|
||||
return ReadName(env, value, &ignored)
|
||||
? static_cast<jstring>(env->NewGlobalRef(value))
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct RobloxAccountBridge::State {
|
||||
struct Endpoint {
|
||||
std::shared_ptr<State> state;
|
||||
Method method;
|
||||
};
|
||||
struct Registration {
|
||||
jobject handler = nullptr;
|
||||
jstring method = nullptr;
|
||||
bool registered = false;
|
||||
};
|
||||
|
||||
jobject bus = nullptr;
|
||||
jstring protocol = nullptr;
|
||||
std::string support_key;
|
||||
std::string token_key;
|
||||
std::string result_key;
|
||||
std::array<Registration, kMethodCount> registrations;
|
||||
std::mutex mutex;
|
||||
std::condition_variable drained;
|
||||
bool accepting = true;
|
||||
std::size_t in_flight = 0;
|
||||
|
||||
// The request payload is never parsed or logged: both answers are fixed.
|
||||
static std::string Answer(void* context, JNIEnv*, jstring) {
|
||||
const auto* endpoint = static_cast<Endpoint*>(context);
|
||||
const auto state = endpoint->state;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (!state->accepting) return {};
|
||||
++state->in_flight;
|
||||
}
|
||||
struct Finish {
|
||||
std::shared_ptr<State> state;
|
||||
~Finish() {
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
--state->in_flight;
|
||||
state->drained.notify_all();
|
||||
}
|
||||
} finish{state};
|
||||
|
||||
nlohmann::json body;
|
||||
const char* result = nullptr;
|
||||
if (endpoint->method == kDeviceIntegrityAvailable) {
|
||||
body = {{state->support_key, false}};
|
||||
result = "unsupported";
|
||||
} else {
|
||||
body = {{state->token_key, ""},
|
||||
{state->result_key, kTokenProviderUninitialized}};
|
||||
result = kTokenProviderUninitialized;
|
||||
}
|
||||
std::fprintf(stderr, " [account] AccountProtocol method=%s result=%s\n",
|
||||
kMethodLabels[endpoint->method], result);
|
||||
return body.dump();
|
||||
}
|
||||
};
|
||||
|
||||
RobloxAccountBridge::RobloxAccountBridge(JniEnvironmentProvider environment,
|
||||
RobloxAccountProtocolSymbols symbols,
|
||||
RobloxAccountProtocolObjects objects)
|
||||
: environment_(environment), symbols_(symbols), objects_(objects) {}
|
||||
|
||||
RobloxAccountBridge::~RobloxAccountBridge() { (void)Shutdown(); }
|
||||
|
||||
Status RobloxAccountBridge::Initialize() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
if (state_) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition,
|
||||
"AccountProtocol is already initialized");
|
||||
}
|
||||
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
|
||||
return Unavailable("AccountProtocol prerequisites are incomplete");
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
|
||||
jclass protocol_class = env->FindClass(kAccountProtocolClass);
|
||||
if (!protocol_class) {
|
||||
(void)CheckJni(env);
|
||||
return Unavailable("JNIAccountProtocol class is unavailable");
|
||||
}
|
||||
const jstring protocol = symbols_.get_protocol_name(env, protocol_class);
|
||||
const jstring methods[kMethodCount] = {
|
||||
symbols_.get_device_integrity_available_method_name(env, protocol_class),
|
||||
symbols_.get_integrity_token_method_name(env, protocol_class)};
|
||||
const jstring keys[] = {symbols_.get_support_key(env, protocol_class),
|
||||
symbols_.get_token_key(env, protocol_class),
|
||||
symbols_.get_result_key(env, protocol_class)};
|
||||
env->DeleteLocalRef(protocol_class);
|
||||
|
||||
state_ = std::make_shared<State>();
|
||||
std::string* const key_targets[] = {&state_->support_key,
|
||||
&state_->token_key, &state_->result_key};
|
||||
bool names_valid = true;
|
||||
for (std::size_t i = 0; i < std::size(keys); ++i) {
|
||||
names_valid = ReadName(env, keys[i], key_targets[i]) && names_valid;
|
||||
if (keys[i]) env->DeleteLocalRef(keys[i]);
|
||||
}
|
||||
state_->protocol = RetainName(env, protocol);
|
||||
if (protocol) env->DeleteLocalRef(protocol);
|
||||
for (std::size_t i = 0; i < kMethodCount; ++i) {
|
||||
state_->registrations[i].method = RetainName(env, methods[i]);
|
||||
names_valid = state_->registrations[i].method && names_valid;
|
||||
if (methods[i]) env->DeleteLocalRef(methods[i]);
|
||||
}
|
||||
state_->bus = env->NewGlobalRef(objects_.message_bus);
|
||||
auto fail = [this](Status failure) {
|
||||
(void)ShutdownLocked();
|
||||
return failure;
|
||||
};
|
||||
if (!CheckJni(env).ok() || !names_valid || !state_->protocol ||
|
||||
!state_->bus) {
|
||||
return fail(Unavailable("AccountProtocol names are unavailable"));
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < kMethodCount; ++i) {
|
||||
auto& entry = state_->registrations[i];
|
||||
auto endpoint = std::make_shared<State::Endpoint>(
|
||||
State::Endpoint{state_, static_cast<Method>(i)});
|
||||
jobject handler = objects_.create_request_handler(objects_.context,
|
||||
endpoint, &State::Answer);
|
||||
if (handler) {
|
||||
entry.handler = env->NewGlobalRef(handler);
|
||||
if (!entry.handler) {
|
||||
objects_.clear_request_handler(objects_.context, handler);
|
||||
}
|
||||
env->DeleteLocalRef(handler);
|
||||
}
|
||||
if (!entry.handler || !CheckJni(env).ok()) {
|
||||
return fail(Unavailable("could not create AccountProtocol handler"));
|
||||
}
|
||||
entry.registered = true;
|
||||
symbols_.set_request_handler_raw(env, state_->bus, state_->protocol,
|
||||
entry.method, entry.handler);
|
||||
status = CheckJni(env);
|
||||
if (!status.ok()) return fail(status);
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [account] AccountProtocol ready device_integrity=unsupported\n");
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxAccountBridge::Shutdown() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
return ShutdownLocked();
|
||||
}
|
||||
|
||||
Status RobloxAccountBridge::ShutdownLocked() {
|
||||
if (!state_) return Status::Ok();
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state_->mutex);
|
||||
state_->accepting = false;
|
||||
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
|
||||
// Clear any exception from partially failed initialization before cleanup.
|
||||
const Status pending = CheckJni(env);
|
||||
if (!pending.ok()) status = pending;
|
||||
for (auto& entry : state_->registrations) {
|
||||
if (entry.registered) {
|
||||
symbols_.clear_request_handler(env, state_->bus, state_->protocol,
|
||||
entry.method);
|
||||
const Status cleared = CheckJni(env);
|
||||
if (!cleared.ok()) status = cleared;
|
||||
}
|
||||
if (entry.handler) {
|
||||
objects_.clear_request_handler(objects_.context, entry.handler);
|
||||
env->DeleteGlobalRef(entry.handler);
|
||||
}
|
||||
if (entry.method) env->DeleteGlobalRef(entry.method);
|
||||
entry = {};
|
||||
}
|
||||
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
|
||||
if (state_->bus) env->DeleteGlobalRef(state_->bus);
|
||||
state_.reset();
|
||||
return status;
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
#include "runtime/external_launch_broker.h"
|
||||
#include "runtime/roblox_launch_uri.h"
|
||||
#include "runtime/webview_helper_launcher.h"
|
||||
#include "window/window.h"
|
||||
|
||||
@@ -21,7 +22,13 @@ namespace {
|
||||
constexpr size_t kMaxPendingLaunchRequests = 8;
|
||||
// libroblox's static TLS requires the proven 16 MiB guest-thread stack floor.
|
||||
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
|
||||
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000};
|
||||
// Covers helper exec through window creation, including cold Flatpak starts.
|
||||
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000};
|
||||
// Roblox closes a solved WebViewProtocol challenge itself with closeWindow,
|
||||
// as on Android. The host closes it only if Roblox has not done so within
|
||||
// this period. Captcha windows close at once instead (see
|
||||
// RobloxWebViewOpenRequest::close_when_challenge_solved).
|
||||
constexpr std::chrono::milliseconds kSolvedChallengeCloseGrace{3000};
|
||||
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
|
||||
|
||||
Status Invalid(std::string message) {
|
||||
@@ -158,24 +165,33 @@ Status LaunchRobloxWebSurface(
|
||||
}
|
||||
std::fprintf(stderr, " [%s] opening validated Roblox web surface\n",
|
||||
transport);
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const WebViewHelperLaunchResult launched =
|
||||
LaunchWebViewHelper(helper, url, std::move(exit_observer));
|
||||
if (!launched) {
|
||||
return Unavailable("could not display Roblox web surface: " +
|
||||
launched.error);
|
||||
}
|
||||
if (launched.process == nullptr ||
|
||||
!launched.process->WaitUntilReady(kWebSurfaceReadyTimeout)) {
|
||||
const bool ready = launched.process != nullptr &&
|
||||
launched.process->WaitUntilReady(kWebSurfaceReadyTimeout);
|
||||
const long long elapsed_ms =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - started)
|
||||
.count();
|
||||
if (!ready) {
|
||||
if (launched.process != nullptr) {
|
||||
(void)launched.process->RequestClose();
|
||||
}
|
||||
return Unavailable(
|
||||
"Roblox web surface did not become ready before the deadline");
|
||||
"Roblox web surface did not become ready before the deadline (" +
|
||||
std::to_string(elapsed_ms) + " ms of " +
|
||||
std::to_string(kWebSurfaceReadyTimeout.count()) + " ms)");
|
||||
}
|
||||
if (process != nullptr) {
|
||||
*process = launched.process;
|
||||
}
|
||||
std::fprintf(stderr, " [%s] Roblox web surface launched\n", transport);
|
||||
std::fprintf(stderr, " [%s] Roblox web surface launched in %lld ms\n",
|
||||
transport, elapsed_ms);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
@@ -299,12 +315,14 @@ RobloxExperienceComposition::RobloxExperienceComposition(
|
||||
const SecureRobloxCredential* initial_web_view_credential,
|
||||
RobloxExperienceSurfaceProvider surface_provider,
|
||||
RobloxExperiencePresenceObserver presence_observer,
|
||||
bool clear_persisted_web_view_cookie, bool microphone_enabled)
|
||||
bool clear_persisted_web_view_cookie, bool microphone_enabled,
|
||||
RobloxAccountProtocolSymbols account_symbols)
|
||||
: environment_(environment),
|
||||
message_bus_symbols_(message_bus_symbols),
|
||||
web_view_symbols_(web_view_symbols),
|
||||
browser_service_symbols_(browser_service_symbols),
|
||||
permissions_symbols_(permissions_symbols),
|
||||
account_symbols_(account_symbols),
|
||||
microphone_enabled_(microphone_enabled),
|
||||
game_symbols_(game_symbols),
|
||||
jni_factory_(jni_factory),
|
||||
@@ -425,11 +443,33 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||
(void)ReleaseGlobalObjects();
|
||||
return status;
|
||||
}
|
||||
// AccountProtocol only shortens a device-integrity login challenge that
|
||||
// cannot succeed here, so its absence or failure never blocks startup.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||
if (account_symbols_.complete()) {
|
||||
account_bridge = std::make_unique<RobloxAccountBridge>(
|
||||
environment_, account_symbols_,
|
||||
RobloxAccountProtocolObjects{web_view_objects.message_bus,
|
||||
jni_factory_.context,
|
||||
jni_factory_.create_request_handler,
|
||||
jni_factory_.clear_request_handler});
|
||||
const Status account_status = account_bridge->Initialize();
|
||||
if (!account_status.ok()) {
|
||||
std::fprintf(stderr, " [account] AccountProtocol unavailable: %s\n",
|
||||
account_status.message().c_str());
|
||||
account_bridge.reset();
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" [account] AccountProtocol entrypoints are missing; "
|
||||
"device-integrity challenges wait for Roblox's timeout\n");
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
web_view_bridge_ = std::move(web_view_bridge);
|
||||
browser_service_bridge_ = std::move(browser_service_bridge);
|
||||
permissions_bridge_ = std::move(permissions_bridge);
|
||||
account_bridge_ = std::move(account_bridge);
|
||||
platform_protocols_initialized_ = true;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
@@ -602,6 +642,8 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
web_surface_process_generation_ = process_generation;
|
||||
web_surface_route_ = route;
|
||||
web_surface_logical_generation_ = logical_generation;
|
||||
web_surface_close_when_challenge_solved_ =
|
||||
presentation.close_when_challenge_solved;
|
||||
web_surface_logical_exit_observer_ = std::move(exit_observer);
|
||||
}
|
||||
if (superseded_observer.valid()) {
|
||||
@@ -614,13 +656,16 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
Status RobloxExperienceComposition::CloseWebSurface(
|
||||
uint64_t expected_logical_generation) {
|
||||
std::lock_guard<std::mutex> operation_lock(web_surface_operation_mutex_);
|
||||
std::shared_ptr<WebViewHelperProcess> process;
|
||||
WebViewHelperExitObserver exit_observer;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (web_surface_route_ == WebSurfaceRoute::kNone) {
|
||||
if (web_surface_route_ == WebSurfaceRoute::kNone ||
|
||||
(expected_logical_generation != 0 &&
|
||||
expected_logical_generation != web_surface_logical_generation_)) {
|
||||
return Status::Ok();
|
||||
}
|
||||
process = std::move(web_surface_process_);
|
||||
@@ -628,6 +673,7 @@ Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
exit_observer = std::move(web_surface_logical_exit_observer_);
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
web_surface_logical_generation_ = 0;
|
||||
web_surface_close_when_challenge_solved_ = false;
|
||||
}
|
||||
// A closed challenge must stop executing. Reusing its hidden document lets
|
||||
// late callbacks from the old attempt reach the next login attempt. The APK
|
||||
@@ -643,6 +689,33 @@ Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::CloseUnclaimedSolvedChallenge() {
|
||||
uint64_t generation = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!solved_challenge_close_deadline_.has_value()) {
|
||||
return Status::Ok();
|
||||
}
|
||||
const bool still_open =
|
||||
web_surface_route_ != WebSurfaceRoute::kNone &&
|
||||
solved_challenge_logical_generation_ == web_surface_logical_generation_;
|
||||
if (still_open &&
|
||||
std::chrono::steady_clock::now() < *solved_challenge_close_deadline_) {
|
||||
return Status::Ok();
|
||||
}
|
||||
solved_challenge_close_deadline_.reset();
|
||||
if (!still_open) {
|
||||
return Status::Ok();
|
||||
}
|
||||
generation = solved_challenge_logical_generation_;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [webview] closing solved challenge window; Roblox did not "
|
||||
"close it within %lld ms\n",
|
||||
static_cast<long long>(kSolvedChallengeCloseGrace.count()));
|
||||
return CloseWebSurface(generation);
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::WebSurfaceExited(void* context) {
|
||||
auto* exit_context = static_cast<WebSurfaceExitContext*>(context);
|
||||
if (exit_context == nullptr || exit_context->target == nullptr ||
|
||||
@@ -668,6 +741,7 @@ void RobloxExperienceComposition::HandleWebSurfaceExit(
|
||||
web_surface_process_generation_ = 0;
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
web_surface_logical_generation_ = 0;
|
||||
web_surface_close_when_challenge_solved_ = false;
|
||||
exit_observer = std::move(web_surface_logical_exit_observer_);
|
||||
}
|
||||
if (exit_observer.valid()) {
|
||||
@@ -693,6 +767,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
|
||||
// host back navigation too.
|
||||
presentation.back_navigation_disabled =
|
||||
request.back_button_visible.has_value() && !*request.back_button_visible;
|
||||
presentation.close_when_challenge_solved =
|
||||
request.close_when_challenge_solved;
|
||||
return composition->OpenWebSurface(request.url, "webview",
|
||||
WebSurfaceRoute::kWebView,
|
||||
std::move(exit_observer), presentation);
|
||||
@@ -951,9 +1027,45 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
|
||||
route = web_surface_route_;
|
||||
web_view_bridge = web_view_bridge_.get();
|
||||
}
|
||||
return event.type == WebViewHelperEventType::kRobloxCookie
|
||||
? AcceptWebViewRobloxCookie(event.payload)
|
||||
: RouteWebSurfaceEvent(route, event, web_view_bridge);
|
||||
if (event.type == WebViewHelperEventType::kRobloxCookie) {
|
||||
return AcceptWebViewRobloxCookie(event.payload);
|
||||
}
|
||||
if (event.type == WebViewHelperEventType::kExecuteRoblox) {
|
||||
// A web Play button's launchGame is launched through the experience
|
||||
// pipeline and closes the surface. It is never forwarded, so Roblox
|
||||
// cannot start a second join from the same command.
|
||||
RobloxExperienceLaunchRequest request;
|
||||
if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() &&
|
||||
Dispatch(request).ok()) {
|
||||
std::fprintf(stderr,
|
||||
"[experience] WebView launchGame queued place_id=%lld\n",
|
||||
static_cast<long long>(request.place_id));
|
||||
return CloseWebSurface();
|
||||
}
|
||||
}
|
||||
const Status status = RouteWebSurfaceEvent(route, event, web_view_bridge);
|
||||
if (!status.ok() || !IsRobloxWebViewChallengeSuccess(event.payload)) {
|
||||
return status;
|
||||
}
|
||||
std::fprintf(stderr, " [webview] solved challenge forwarded to Roblox\n");
|
||||
bool close_now = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (logical_generation != web_surface_logical_generation_) {
|
||||
return status;
|
||||
}
|
||||
close_now = web_surface_close_when_challenge_solved_;
|
||||
if (!close_now) {
|
||||
solved_challenge_close_deadline_ =
|
||||
std::chrono::steady_clock::now() + kSolvedChallengeCloseGrace;
|
||||
solved_challenge_logical_generation_ = logical_generation;
|
||||
}
|
||||
}
|
||||
if (!close_now) {
|
||||
return status;
|
||||
}
|
||||
std::fprintf(stderr, " [webview] closing solved captcha window\n");
|
||||
return CloseWebSurface(logical_generation);
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::DrainPlatformEvents() {
|
||||
@@ -1002,6 +1114,9 @@ Status RobloxExperienceComposition::DrainPlatformEvents() {
|
||||
status = drain_helper(web_surface_process, process_generation,
|
||||
logical_generation);
|
||||
}
|
||||
if (status.ok()) {
|
||||
status = CloseUnclaimedSolvedChallenge();
|
||||
}
|
||||
if (status.ok() && browser_service_bridge != nullptr) {
|
||||
status = browser_service_bridge->DrainOutgoingEvents();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include "runtime/environment.h"
|
||||
#include "webview/roblox_challenge_features.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -401,6 +402,39 @@ Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command) {
|
||||
if (command.empty() || command.size() > kMaximumRobloxWebViewJsonBytes) {
|
||||
return false;
|
||||
}
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(command.begin(), command.end(), nullptr, false);
|
||||
const auto string_field = [](const nlohmann::json& object,
|
||||
const char* key) -> const std::string* {
|
||||
const auto field = object.find(key);
|
||||
return field != object.end() && field->is_string()
|
||||
? &field->get_ref<const std::string&>()
|
||||
: nullptr;
|
||||
};
|
||||
const auto object_field = [](const nlohmann::json& object,
|
||||
const char* key) -> const nlohmann::json* {
|
||||
const auto field = object.find(key);
|
||||
return field != object.end() && field->is_object() ? &*field : nullptr;
|
||||
};
|
||||
if (!document.is_object()) {
|
||||
return false;
|
||||
}
|
||||
const std::string* module = string_field(document, "moduleID");
|
||||
const std::string* function = string_field(document, "functionName");
|
||||
const nlohmann::json* outer = object_field(document, "params");
|
||||
const nlohmann::json* navigation =
|
||||
outer != nullptr ? object_field(*outer, "params") : nullptr;
|
||||
const std::string* feature =
|
||||
navigation != nullptr ? string_field(*navigation, "feature") : nullptr;
|
||||
return module != nullptr && *module == "Navigation" && function != nullptr &&
|
||||
*function == "navigateToFeature" && feature != nullptr &&
|
||||
webview::IsRobloxChallengeSolvedFeature(*feature);
|
||||
}
|
||||
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request) {
|
||||
if (request == nullptr) {
|
||||
@@ -911,6 +945,20 @@ Status RobloxWebViewBridge::Initialize() {
|
||||
// handler and window subscriptions are installed before the native Android
|
||||
// protocol is initialized. Calling the native initializer earlier loses
|
||||
// messages that it can publish synchronously during startup.
|
||||
// Roblox's Android app start holds a WebViewProtocol handle for the process
|
||||
// lifetime. Without one, the protocol is deleted as soon as each caller
|
||||
// releases it, and page callbacks wait for the next Lua WebView call to
|
||||
// recreate it. Taking it before the native initializer keeps the protocol
|
||||
// that initializer configures.
|
||||
if (symbols_.acquire_web_view_protocol != nullptr) {
|
||||
if (web_view_protocol_handle_.holder() == nullptr) {
|
||||
web_view_protocol_handle_ = symbols_.acquire_web_view_protocol();
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" [webview] WebViewProtocol getter not recognized; page "
|
||||
"callbacks may wait for the next Roblox WebView call\n");
|
||||
}
|
||||
jclass initialization_class = env->FindClass(kWebViewProtocolClass);
|
||||
if (initialization_class != nullptr) {
|
||||
symbols_.initialize_android_web_view_protocol(env, initialization_class);
|
||||
@@ -1266,6 +1314,7 @@ Status RobloxWebViewBridge::DispatchOpenRequest(
|
||||
exit_context->target->browser_login_fallback = browser_login_fallback;
|
||||
}
|
||||
}
|
||||
request.close_when_challenge_solved = source == OpenSource::kCaptcha;
|
||||
if (status.ok()) {
|
||||
status = sink_.dispatch_open(
|
||||
sink_.context, request,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
#include <unistd.h>
|
||||
#include <utility>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
@@ -438,8 +440,8 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
|
||||
const std::filesystem::path library = canonical_root / "libroblox.so";
|
||||
const std::filesystem::path package_root = canonical_root / "sober_apk";
|
||||
const std::filesystem::path base_apk = package_root / "base.apk";
|
||||
const std::filesystem::path x86_64_split_apk =
|
||||
package_root / "split_config.x86_64.apk";
|
||||
const std::filesystem::path arch_split_apk =
|
||||
package_root / compat::kGuestSplitApkFile;
|
||||
const std::filesystem::path assets_root = canonical_root / "assets";
|
||||
const std::filesystem::path assets = canonical_root / "assets/content";
|
||||
const std::filesystem::file_status library_status =
|
||||
@@ -460,7 +462,8 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
|
||||
for (const auto& [path, name] :
|
||||
std::array<std::pair<std::filesystem::path, const char*>, 2>{
|
||||
std::pair{base_apk, "base.apk"},
|
||||
std::pair{x86_64_split_apk, "split_config.x86_64.apk"}}) {
|
||||
std::pair{arch_split_apk,
|
||||
compat::kGuestSplitApkFile.data()}}) {
|
||||
const std::filesystem::file_status status =
|
||||
std::filesystem::symlink_status(path, filesystem_error);
|
||||
if (filesystem_error || !std::filesystem::is_regular_file(status) ||
|
||||
@@ -489,7 +492,7 @@ ActivePayloadPaths RuntimePaths::ResolveActivePayload() const {
|
||||
result.root = canonical_root;
|
||||
result.roblox_library = library;
|
||||
result.base_apk = base_apk;
|
||||
result.x86_64_split_apk = x86_64_split_apk;
|
||||
result.arch_split_apk = arch_split_apk;
|
||||
result.assets_content = assets;
|
||||
|
||||
const auto compatibility_manifest =
|
||||
|
||||
+25
-13
@@ -28,6 +28,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/android_manifest.h"
|
||||
#include "update/apk_signature.h"
|
||||
#include "update/payload_integrity.h"
|
||||
@@ -173,10 +174,15 @@ bool EndsWith(std::string_view value, std::string_view suffix) {
|
||||
struct ApkCandidate {
|
||||
std::filesystem::path path;
|
||||
AndroidManifestIdentity identity;
|
||||
bool has_x86_64_library = false;
|
||||
bool has_guest_library = false;
|
||||
bool has_assets = false;
|
||||
};
|
||||
|
||||
std::string GuestLibEntryName() {
|
||||
return std::string(compat::kGuestLibApkEntryPrefix) +
|
||||
std::string(compat::kGuestAbi) + "/libroblox.so";
|
||||
}
|
||||
|
||||
bool CollectCandidates(const std::vector<std::filesystem::path>& archives,
|
||||
const std::filesystem::path& directory,
|
||||
std::vector<std::filesystem::path>* candidates,
|
||||
@@ -250,7 +256,7 @@ bool InspectCandidates(const std::vector<std::filesystem::path>& paths,
|
||||
ApkCandidate candidate;
|
||||
candidate.path = path;
|
||||
candidate.identity = std::move(identity);
|
||||
candidate.has_x86_64_library = HasEntry(list, "lib/x86_64/libroblox.so");
|
||||
candidate.has_guest_library = HasEntry(list, GuestLibEntryName());
|
||||
candidate.has_assets = std::any_of(
|
||||
list.entries.begin(), list.entries.end(), [](const ZipEntry& entry) {
|
||||
return entry.name.size() > 7 && entry.name.substr(0, 7) == "assets/";
|
||||
@@ -314,7 +320,8 @@ std::vector<std::string> CommonTrustedCertificates(
|
||||
const ApkSignatureResult split_signature =
|
||||
base == split ? base_signature : VerifyApkSignature(split);
|
||||
if (!split_signature) {
|
||||
*error = "x86_64 APK signature: " + split_signature.error;
|
||||
*error = std::string(compat::kGuestAbi) +
|
||||
" APK signature: " + split_signature.error;
|
||||
return {};
|
||||
}
|
||||
std::vector<std::string> accepted;
|
||||
@@ -356,9 +363,11 @@ bool ValidateElf(const std::filesystem::path& path, std::string* build_id,
|
||||
}
|
||||
GElf_Ehdr header{};
|
||||
if (gelf_getehdr(elf.get(), &header) == nullptr ||
|
||||
header.e_machine != EM_X86_64 || gelf_getclass(elf.get()) != ELFCLASS64) {
|
||||
header.e_machine != compat::kGuestElfMachine ||
|
||||
gelf_getclass(elf.get()) != ELFCLASS64) {
|
||||
close_elf();
|
||||
*error = "libroblox.so is not an x86-64 ELF";
|
||||
*error = "libroblox.so is not a " + std::string(compat::kGuestAbi) +
|
||||
" ELF";
|
||||
return false;
|
||||
}
|
||||
std::size_t section_count = 0;
|
||||
@@ -464,16 +473,18 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
base = &candidate;
|
||||
}
|
||||
}
|
||||
const std::string guest_split_name(compat::kGuestApkSplitName);
|
||||
for (const ApkCandidate& candidate : candidates) {
|
||||
if (candidate.has_x86_64_library &&
|
||||
(candidate.identity.split_name == "config.x86_64" ||
|
||||
if (candidate.has_guest_library &&
|
||||
(candidate.identity.split_name == guest_split_name ||
|
||||
candidate.identity.split_name.empty()) &&
|
||||
split == nullptr) {
|
||||
split = &candidate;
|
||||
}
|
||||
}
|
||||
if (base == nullptr || split == nullptr) {
|
||||
result.error = "no matching Roblox base and x86_64 APK pair was found";
|
||||
result.error = "no matching Roblox base and " +
|
||||
std::string(compat::kGuestAbi) + " APK pair was found";
|
||||
return result;
|
||||
}
|
||||
const std::set<std::string> trusted =
|
||||
@@ -488,12 +499,13 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
if (filesystem_error ||
|
||||
!CopyRegular(base->path, prepared / "sober_apk/base.apk",
|
||||
&result.error) ||
|
||||
!CopyRegular(split->path, prepared / "sober_apk/split_config.x86_64.apk",
|
||||
!CopyRegular(split->path,
|
||||
prepared / "sober_apk" / compat::kGuestSplitApkFile,
|
||||
&result.error)) {
|
||||
if (result.error.empty()) result.error = "cannot create prepared payload";
|
||||
return result;
|
||||
}
|
||||
if (!ExtractZipEntry(split->path, "lib/x86_64/libroblox.so",
|
||||
if (!ExtractZipEntry(split->path, GuestLibEntryName(),
|
||||
prepared / "libroblox.so", kMaximumApkBytes,
|
||||
&result.error)) {
|
||||
return result;
|
||||
@@ -527,7 +539,7 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
const std::string base_hash =
|
||||
HashRegularFile(prepared / "sober_apk/base.apk", &hash_error);
|
||||
const std::string split_hash = HashRegularFile(
|
||||
prepared / "sober_apk/split_config.x86_64.apk", &hash_error);
|
||||
prepared / "sober_apk" / compat::kGuestSplitApkFile, &hash_error);
|
||||
if (!hash_error.empty()) {
|
||||
result.error = hash_error;
|
||||
return result;
|
||||
@@ -537,12 +549,12 @@ PreparedPayload PreparePayloadFromArchives(
|
||||
{"package", "com.roblox.client"},
|
||||
{"version_name", expected.version_name},
|
||||
{"version_code", expected.version_code},
|
||||
{"abi", "x86_64"},
|
||||
{"abi", std::string(compat::kGuestAbi)},
|
||||
{"elf_build_id", build_id},
|
||||
{"sha256",
|
||||
{{"libroblox", library_hash},
|
||||
{"base_apk", base_hash},
|
||||
{"x86_64_split_apk", split_hash}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey), split_hash}}},
|
||||
{"signing_certificates_sha256", accepted},
|
||||
{"assets",
|
||||
{{"file_count", asset_count_verified},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/http_download.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -199,7 +200,8 @@ std::vector<std::string> ParseApkPureExactDownloadUrls(
|
||||
}
|
||||
|
||||
ProviderVersion ApkPureProvider::CheckLatest() const {
|
||||
const HttpBytesResult metadata = FetchMetadata("x86_64");
|
||||
const HttpBytesResult metadata =
|
||||
FetchMetadata(std::string(compat::kGuestAbi));
|
||||
if (!metadata) return ProviderVersion{{}, 0, metadata.error};
|
||||
return ParseApkPureLatestMetadata(metadata.bytes);
|
||||
}
|
||||
@@ -223,7 +225,7 @@ ProviderDownloadResult ApkPureProvider::DownloadExact(
|
||||
result.error = "APKPure output directory must exist and be empty";
|
||||
return result;
|
||||
}
|
||||
HttpBytesResult metadata = FetchMetadata("x86_64");
|
||||
HttpBytesResult metadata = FetchMetadata(std::string(compat::kGuestAbi));
|
||||
if (!metadata) {
|
||||
result.error = metadata.error;
|
||||
return result;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/payload_integrity.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -260,7 +261,7 @@ std::string PayloadRuntimeFingerprint(
|
||||
evidence.append("base_apk=")
|
||||
.append(payload.metadata.base_apk_sha256)
|
||||
.append("\n");
|
||||
evidence.append("x86_64_split=")
|
||||
evidence.append(std::string(compat::kGuestAbi) + "_split=")
|
||||
.append(payload.metadata.split_apk_sha256)
|
||||
.append("\n");
|
||||
evidence.append("assets=")
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
#include <cerrno>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
namespace {
|
||||
@@ -81,6 +84,16 @@ CompatibilityCatalogResult LoadCompatibilityCatalog(
|
||||
profile.value("allow_legacy_binary_patches", true)) {
|
||||
continue;
|
||||
}
|
||||
const auto declared_abi = profile.find("abi");
|
||||
if (declared_abi != profile.end() && !declared_abi->is_string()) {
|
||||
result.error = "supported compatibility profile is incomplete";
|
||||
return result;
|
||||
}
|
||||
const std::string_view profile_abi =
|
||||
declared_abi == profile.end()
|
||||
? std::string_view("x86_64")
|
||||
: std::string_view(declared_abi->get_ref<const std::string&>());
|
||||
if (profile_abi != compat::kGuestAbi) continue;
|
||||
if (!profile.contains("version_name") ||
|
||||
!profile["version_name"].is_string() ||
|
||||
!profile.contains("version_code") ||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
#include "compat/elf_build_id.h"
|
||||
#include "compat/fmod_output_device_contract.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "update/payload_integrity.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
@@ -222,9 +223,10 @@ class ElfImage final {
|
||||
if (elf_ == nullptr || elf_kind(elf_) != ELF_K_ELF ||
|
||||
gelf_getehdr(elf_, &header) == nullptr ||
|
||||
gelf_getclass(elf_) != ELFCLASS64 || header.e_type != ET_DYN ||
|
||||
header.e_machine != EM_X86_64 || header.e_version != EV_CURRENT ||
|
||||
header.e_machine != compat::kGuestElfMachine ||
|
||||
header.e_version != EV_CURRENT ||
|
||||
header.e_ident[EI_DATA] != ELFDATA2LSB) {
|
||||
*error = "candidate must be a little-endian x86-64 ET_DYN ELF";
|
||||
*error = "candidate must be a little-endian guest-abi ET_DYN ELF";
|
||||
return false;
|
||||
}
|
||||
std::size_t program_count = 0;
|
||||
@@ -1901,7 +1903,7 @@ std::optional<Json> LoadCandidateMetadata(const std::filesystem::path& root,
|
||||
metadata->contains("sha256") ? &(*metadata)["sha256"] : nullptr;
|
||||
if (metadata->value("schema_version", 0) != 1 ||
|
||||
metadata->value("package", "") != "com.roblox.client" ||
|
||||
metadata->value("abi", "") != "x86_64" || version_name.empty() ||
|
||||
metadata->value("abi", "") != compat::kGuestAbi || version_name.empty() ||
|
||||
version_code == 0 ||
|
||||
metadata->value("elf_build_id", "") != candidate.build_id() ||
|
||||
hashes == nullptr || !hashes->is_object() ||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
namespace {
|
||||
|
||||
@@ -291,7 +293,7 @@ PayloadIntegrityResult InspectPreparedPayload(
|
||||
for (const std::filesystem::path& relative : {
|
||||
std::filesystem::path("libroblox.so"),
|
||||
std::filesystem::path("sober_apk/base.apk"),
|
||||
std::filesystem::path("sober_apk/split_config.x86_64.apk"),
|
||||
std::filesystem::path("sober_apk") / compat::kGuestSplitApkFile,
|
||||
std::filesystem::path("roblox_payload.json"),
|
||||
}) {
|
||||
const auto status =
|
||||
@@ -335,7 +337,7 @@ PayloadIntegrityResult InspectPreparedPayload(
|
||||
result.metadata.library_sha256 = document["sha256"].value("libroblox", "");
|
||||
result.metadata.base_apk_sha256 = document["sha256"].value("base_apk", "");
|
||||
result.metadata.split_apk_sha256 =
|
||||
document["sha256"].value("x86_64_split_apk", "");
|
||||
document["sha256"].value(std::string(compat::kGuestSplitApkHashKey), "");
|
||||
if (!document["assets"].contains("file_count") ||
|
||||
!document["assets"]["file_count"].is_number_unsigned()) {
|
||||
result.error = "payload metadata asset count is invalid";
|
||||
@@ -368,7 +370,8 @@ PayloadIntegrityResult VerifyPreparedPayload(
|
||||
const std::array<std::pair<std::filesystem::path, std::string>, 3> files = {{
|
||||
{"libroblox.so", result.metadata.library_sha256},
|
||||
{"sober_apk/base.apk", result.metadata.base_apk_sha256},
|
||||
{"sober_apk/split_config.x86_64.apk", result.metadata.split_apk_sha256},
|
||||
{std::filesystem::path("sober_apk") / compat::kGuestSplitApkFile,
|
||||
result.metadata.split_apk_sha256},
|
||||
}};
|
||||
for (const auto& [relative, expected] : files) {
|
||||
const std::string actual =
|
||||
|
||||
@@ -108,15 +108,9 @@ struct SurfaceState {
|
||||
unsigned int resource_retries = 0;
|
||||
};
|
||||
|
||||
// A solved challenge stays open: the host forwards the event to Roblox, and
|
||||
// Roblox or the host closes the window after Roblox has received it.
|
||||
struct CallbackConfirmation {
|
||||
CallbackConfirmation(GtkWindow* surface_window, CaptchaEventType event_type)
|
||||
: type(event_type) {
|
||||
g_weak_ref_init(&window, G_OBJECT(surface_window));
|
||||
}
|
||||
|
||||
~CallbackConfirmation() { g_weak_ref_clear(&window); }
|
||||
|
||||
GWeakRef window;
|
||||
CaptchaEventType type = CaptchaEventType::kShown;
|
||||
};
|
||||
|
||||
@@ -612,16 +606,6 @@ void FinishCallbackConfirmation(GObject* source_object, GAsyncResult* result,
|
||||
if (value != nullptr) {
|
||||
g_object_unref(value);
|
||||
}
|
||||
|
||||
auto* window_object =
|
||||
static_cast<GObject*>(g_weak_ref_get(&confirmation->window));
|
||||
if (confirmed && confirmation->type == CaptchaEventType::kSuccess &&
|
||||
window_object != nullptr) {
|
||||
gtk_window_close(GTK_WINDOW(window_object));
|
||||
}
|
||||
if (window_object != nullptr) {
|
||||
g_object_unref(window_object);
|
||||
}
|
||||
delete confirmation;
|
||||
}
|
||||
|
||||
@@ -644,9 +628,8 @@ void HandleHybridCommand(WebKitUserContentManager* manager,
|
||||
if (window_object == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto* confirmation =
|
||||
new CallbackConfirmation(GTK_WINDOW(window_object), event.type);
|
||||
g_object_unref(window_object);
|
||||
auto* confirmation = new CallbackConfirmation{event.type};
|
||||
const std::string script = BuildCallbackScript(event.callback_id);
|
||||
WebKitWebView* web_view = WEBKIT_WEB_VIEW(
|
||||
g_object_get_data(G_OBJECT(manager), "mocktail-web-view"));
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "webview/roblox_challenge_features.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace webview {
|
||||
namespace {
|
||||
@@ -306,7 +308,7 @@ bool ParseCaptchaEvent(std::string_view command, CaptchaEvent* event) {
|
||||
event->type = CaptchaEventType::kShown;
|
||||
return true;
|
||||
}
|
||||
if (feature == "CaptchaSuccess") {
|
||||
if (IsRobloxChallengeSolvedFeature(feature)) {
|
||||
event->type = CaptchaEventType::kSuccess;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -370,6 +370,15 @@ target_link_libraries(roblox_permissions_bridge_test PRIVATE
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_permissions_bridge_test)
|
||||
|
||||
add_executable(roblox_account_bridge_test roblox_account_bridge_test.cc)
|
||||
target_link_libraries(roblox_account_bridge_test PRIVATE
|
||||
Mocktail::LegacyJni
|
||||
Mocktail::Runtime
|
||||
GTest::gtest_main
|
||||
Threads::Threads
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_account_bridge_test)
|
||||
|
||||
add_executable(roblox_browser_service_bridge_test
|
||||
roblox_browser_service_bridge_test.cc
|
||||
)
|
||||
@@ -713,6 +722,15 @@ target_link_libraries(elf_build_id_test PRIVATE
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
add_executable(web_view_protocol_contract_test
|
||||
web_view_protocol_contract_test.cc
|
||||
)
|
||||
target_link_libraries(web_view_protocol_contract_test PRIVATE
|
||||
Mocktail::Compat
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(web_view_protocol_contract_test)
|
||||
|
||||
add_executable(build_profile_test build_profile_test.cc)
|
||||
target_link_libraries(build_profile_test PRIVATE
|
||||
Mocktail::Compat
|
||||
@@ -1006,6 +1024,7 @@ gtest_discover_tests(roblox_game_session_native_adapter_test)
|
||||
gtest_discover_tests(roblox_experience_launch_bridge_test)
|
||||
gtest_discover_tests(roblox_web_view_bridge_test)
|
||||
gtest_discover_tests(roblox_permissions_bridge_test)
|
||||
gtest_discover_tests(roblox_account_bridge_test)
|
||||
gtest_discover_tests(roblox_browser_service_bridge_test)
|
||||
gtest_discover_tests(roblox_fresh_game_launch_controller_test)
|
||||
gtest_discover_tests(roblox_experience_composition_test)
|
||||
@@ -1039,6 +1058,7 @@ gtest_discover_tests(auth_service_test)
|
||||
gtest_discover_tests(auth_runtime_composition_test)
|
||||
gtest_discover_tests(client_settings_service_test)
|
||||
gtest_discover_tests(elf_build_id_test)
|
||||
gtest_discover_tests(web_view_protocol_contract_test)
|
||||
gtest_discover_tests(build_profile_test)
|
||||
gtest_discover_tests(host_abi_profile_test)
|
||||
gtest_discover_tests(payload_compatibility_host_abi_gate_test)
|
||||
|
||||
@@ -45,6 +45,8 @@ jq --arg hash "${UPDATED_FFMPEG_HASH}" '
|
||||
mkdir -p "${TEMP_DIR}/project/scripts" "${TEMP_DIR}/project/packaging"
|
||||
cp "${ROOT}/scripts/install_anylinux_dependencies.sh" "${TEMP_DIR}/project/scripts/"
|
||||
cp "${TEMP_DIR}/manifest.txt" "${TEMP_DIR}/project/packaging/anylinux-dependencies.txt"
|
||||
cp "${ROOT}/packaging/anylinux-dependencies-aarch64.txt" \
|
||||
"${TEMP_DIR}/project/packaging/"
|
||||
|
||||
# Record requests without any network access or changes to host packages.
|
||||
curl() {
|
||||
@@ -121,13 +123,13 @@ ExpectRejectedSnapshot() {
|
||||
local description="$1" filter="$2"
|
||||
jq "${filter}" "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
|
||||
"${TEMP_DIR}/invalid.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
"${TEMP_DIR}/invalid.json" x86_64) >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
Fail "accepted ${description}"
|
||||
fi
|
||||
}
|
||||
|
||||
(AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
|
||||
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/snapshot.tsv" ||
|
||||
"${TEMP_DIR}/release.json" x86_64) >"${TEMP_DIR}/snapshot.tsv" ||
|
||||
Fail 'rejected valid dependency snapshot'
|
||||
printf '%s\t%s\t%s\n' \
|
||||
"${FFMPEG_HASH}" "${FFMPEG}" "${API}/assets/10" \
|
||||
@@ -136,6 +138,40 @@ printf '%s\t%s\t%s\n' \
|
||||
cmp "${TEMP_DIR}/expected-snapshot.tsv" "${TEMP_DIR}/snapshot.tsv" ||
|
||||
Fail 'incorrect dependency snapshot'
|
||||
|
||||
readonly ARM_PACKAGE=gdk-pixbuf2-mini-aarch64.pkg.tar.xz
|
||||
printf '%s\n' "${ARM_PACKAGE}" >"${TEMP_DIR}/arm-manifest.txt"
|
||||
jq --null-input --arg api "${API}" --arg name "${ARM_PACKAGE}" \
|
||||
--arg hash "${FFMPEG_HASH}" '
|
||||
{tag_name: "continuous", assets: [
|
||||
{id: 40, name: $name, state: "uploaded",
|
||||
digest: ("sha256:" + $hash), url: ($api + "/assets/40")}
|
||||
]}
|
||||
' >"${TEMP_DIR}/arm-release.json"
|
||||
AnyLinuxDependencySnapshot "${TEMP_DIR}/arm-manifest.txt" \
|
||||
"${TEMP_DIR}/arm-release.json" aarch64 >"${TEMP_DIR}/arm-snapshot.tsv" ||
|
||||
Fail 'rejected valid aarch64 dependency snapshot'
|
||||
printf '%s\t%s\t%s\n' "${FFMPEG_HASH}" "${ARM_PACKAGE}" \
|
||||
"${API}/assets/40" >"${TEMP_DIR}/expected-arm-snapshot.tsv"
|
||||
cmp "${TEMP_DIR}/expected-arm-snapshot.tsv" "${TEMP_DIR}/arm-snapshot.tsv" ||
|
||||
Fail 'incorrect aarch64 dependency snapshot'
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/arm-manifest.txt" \
|
||||
"${TEMP_DIR}/arm-release.json" x86_64) >/dev/null 2>&1; then
|
||||
Fail 'accepted an aarch64 dependency on an x86_64 host'
|
||||
fi
|
||||
bash -Eeuo pipefail -c '
|
||||
source "$1/project/scripts/install_anylinux_dependencies.sh"
|
||||
AnyLinuxDependencyRequireContainer() { :; }
|
||||
uname() { printf "aarch64\n"; }
|
||||
AnyLinuxDownloadDependencies() {
|
||||
printf "%s\n" "$1" >"${TEMP_DIR}/arm-selected-manifest"
|
||||
exit 0
|
||||
}
|
||||
AnyLinuxDependenciesMain
|
||||
' _ "${TEMP_DIR}" || Fail 'aarch64 manifest selection failed'
|
||||
grep -Fxq "${TEMP_DIR}/project/packaging/anylinux-dependencies-aarch64.txt" \
|
||||
"${TEMP_DIR}/arm-selected-manifest" ||
|
||||
Fail 'aarch64 build did not select its dependency manifest'
|
||||
|
||||
ExpectRejectedSnapshot 'missing asset' '.assets |= .[1:]'
|
||||
ExpectRejectedSnapshot 'duplicate asset' '.assets += [.assets[0]]'
|
||||
ExpectRejectedSnapshot 'absent SHA256' '.assets[0].digest = null'
|
||||
@@ -149,7 +185,7 @@ for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
|
||||
"${FFMPEG} extra" "${FFMPEG}"$'\n'"${FFMPEG}" ''; do
|
||||
printf '%s\n' "${invalid_manifest}" >"${TEMP_DIR}/invalid-manifest.txt"
|
||||
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/invalid-manifest.txt" \
|
||||
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
"${TEMP_DIR}/release.json" x86_64) >"${TEMP_DIR}/invalid.log" 2>&1; then
|
||||
Fail 'accepted unsafe, duplicate, or empty package allowlist'
|
||||
fi
|
||||
done
|
||||
@@ -166,6 +202,7 @@ for SCENARIO in success replaced corrupted download-failure metadata-failure inv
|
||||
if GH_TOKEN=fixture-token bash -Eeuo pipefail -c '
|
||||
source "$1/project/scripts/install_anylinux_dependencies.sh"
|
||||
AnyLinuxDependencyRequireContainer() { :; }
|
||||
uname() { printf "x86_64\n"; }
|
||||
AnyLinuxDependenciesMain
|
||||
' _ "${TEMP_DIR}" \
|
||||
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "compat/bionic_abi_exports.h"
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <fcntl.h>
|
||||
@@ -37,8 +39,10 @@ TEST(BionicAbiExportsTest, ReportsStableAndroidSystemProperties) {
|
||||
EXPECT_STREQ("35", value.data());
|
||||
|
||||
value.fill('\0');
|
||||
EXPECT_EQ(6, __system_property_get("ro.product.cpu.abi", value.data()));
|
||||
EXPECT_STREQ("x86_64", value.data());
|
||||
EXPECT_EQ(static_cast<int>(mocktail::compat::kGuestAbi.size()),
|
||||
__system_property_get("ro.product.cpu.abi", value.data()));
|
||||
EXPECT_STREQ(std::string(mocktail::compat::kGuestAbi).c_str(),
|
||||
value.data());
|
||||
|
||||
value.fill('x');
|
||||
EXPECT_EQ(0, __system_property_get("mocktail.unknown", value.data()));
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <sched.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "compat/bionic_abi_exports.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
|
||||
@@ -189,9 +191,10 @@ TEST(BionicPthreadCreateRuntimeTest, GrowsRejectedExplicitGuestStack) {
|
||||
0);
|
||||
ASSERT_EQ(pthread_attr_destroy(&host_defaults), 0);
|
||||
|
||||
pthread_attr_t guest_attributes;
|
||||
ASSERT_EQ(pthread_attr_init(&guest_attributes), 0);
|
||||
ASSERT_EQ(pthread_attr_setstacksize(&guest_attributes, kRequestedStackSize),
|
||||
MocktailBionicPthreadAttr guest_attributes;
|
||||
ASSERT_EQ(mocktail_pthread_attr_init(&guest_attributes), 0);
|
||||
ASSERT_EQ(mocktail_pthread_attr_setstacksize(&guest_attributes,
|
||||
kRequestedStackSize),
|
||||
0);
|
||||
|
||||
g_intercepted_create_calls.store(0, std::memory_order_release);
|
||||
@@ -206,7 +209,7 @@ TEST(BionicPthreadCreateRuntimeTest, GrowsRejectedExplicitGuestStack) {
|
||||
&thread, &guest_attributes, RecordThreadStackSize, nullptr);
|
||||
g_intercept_pthread_create.store(false, std::memory_order_release);
|
||||
|
||||
ASSERT_EQ(pthread_attr_destroy(&guest_attributes), 0);
|
||||
ASSERT_EQ(mocktail_pthread_attr_destroy(&guest_attributes), 0);
|
||||
ASSERT_EQ(result, 0);
|
||||
ASSERT_EQ(pthread_join(thread, nullptr), 0);
|
||||
EXPECT_EQ(g_intercepted_create_calls.load(std::memory_order_acquire), 3);
|
||||
@@ -223,15 +226,16 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
|
||||
constexpr size_t kRequestedStackSize = 2 * 1024 * 1024;
|
||||
g_observed_stack_size.store(0, std::memory_order_release);
|
||||
|
||||
pthread_attr_t attributes;
|
||||
ASSERT_EQ(pthread_attr_init(&attributes), 0);
|
||||
ASSERT_EQ(pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
|
||||
MocktailBionicPthreadAttr attributes;
|
||||
ASSERT_EQ(mocktail_pthread_attr_init(&attributes), 0);
|
||||
ASSERT_EQ(
|
||||
mocktail_pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
|
||||
|
||||
pthread_t thread{};
|
||||
ASSERT_EQ(mocktail_bionic_pthread_create(&thread, &attributes,
|
||||
RecordThreadStackSize, nullptr),
|
||||
0);
|
||||
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
|
||||
EXPECT_EQ(mocktail_pthread_attr_destroy(&attributes), 0);
|
||||
ASSERT_EQ(pthread_join(thread, nullptr), 0);
|
||||
ExpectRequestedStackSize(
|
||||
g_observed_stack_size.load(std::memory_order_acquire),
|
||||
@@ -240,13 +244,13 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
|
||||
|
||||
TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
|
||||
for (const int policy : {SCHED_FIFO, SCHED_RR}) {
|
||||
pthread_attr_t attributes;
|
||||
ASSERT_EQ(pthread_attr_init(&attributes), 0);
|
||||
ASSERT_EQ(pthread_attr_setinheritsched(&attributes, PTHREAD_EXPLICIT_SCHED), 0);
|
||||
ASSERT_EQ(pthread_attr_setschedpolicy(&attributes, policy), 0);
|
||||
MocktailBionicPthreadAttr attributes;
|
||||
ASSERT_EQ(mocktail_pthread_attr_init(&attributes), 0);
|
||||
attributes.sched_policy = policy;
|
||||
sched_param parameters{};
|
||||
parameters.sched_priority = 99;
|
||||
ASSERT_EQ(pthread_attr_setschedparam(&attributes, ¶meters), 0);
|
||||
ASSERT_EQ(
|
||||
mocktail_pthread_attr_setschedparam(&attributes, ¶meters), 0);
|
||||
g_intercepted_create_calls.store(0);
|
||||
g_intercept_pthread_create.store(true);
|
||||
pthread_t thread{};
|
||||
@@ -255,7 +259,7 @@ TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
|
||||
g_intercept_pthread_create.store(false);
|
||||
EXPECT_EQ(result, EPERM);
|
||||
EXPECT_EQ(g_intercepted_create_calls.load(), 0);
|
||||
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
|
||||
EXPECT_EQ(mocktail_pthread_attr_destroy(&attributes), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,69 @@ TEST(ChromeTraceWriterTest, ActiveProfileTraceIsOffWithoutEnvironment) {
|
||||
EXPECT_EQ(ActiveProfileTrace(), nullptr);
|
||||
}
|
||||
|
||||
TEST(ChromeTraceWriterTest, CpuClocksAdvanceWithWork) {
|
||||
// The process window encloses the thread window.
|
||||
const std::uint64_t process_before = TraceProcessCpuNanos();
|
||||
const std::uint64_t thread_before = TraceThreadCpuNanos();
|
||||
volatile std::uint64_t sink = 0;
|
||||
const auto until =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(20);
|
||||
while (std::chrono::steady_clock::now() < until) {
|
||||
sink = sink + 1;
|
||||
}
|
||||
const std::uint64_t thread_used = TraceThreadCpuNanos() - thread_before;
|
||||
const std::uint64_t process_used = TraceProcessCpuNanos() - process_before;
|
||||
EXPECT_GE(thread_used, 10'000'000U);
|
||||
EXPECT_GE(process_used, thread_used);
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, FirstPresentHasNoFrame) {
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
EXPECT_FALSE(sampler.Sample(1, 10'000'000, 50'000'000, &sample));
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, MeasuresCpuSpentSinceThePreviousPresent) {
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
|
||||
ASSERT_TRUE(sampler.Sample(1, 18'000'000, 90'000'000, &sample));
|
||||
EXPECT_TRUE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.thread_ms, 8.0);
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 40.0);
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, SkipsThreadCpuWhenAnotherThreadPresentedLast) {
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
|
||||
ASSERT_TRUE(sampler.Sample(2, 3'000'000, 70'000'000, &sample));
|
||||
EXPECT_FALSE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 20.0);
|
||||
|
||||
// Thread 2 presented last, so its next frame measures from here.
|
||||
ASSERT_TRUE(sampler.Sample(2, 7'000'000, 80'000'000, &sample));
|
||||
EXPECT_TRUE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.thread_ms, 4.0);
|
||||
}
|
||||
|
||||
TEST(FrameCpuSamplerTest, ClocksReadOutOfOrderDoNotWrap) {
|
||||
// Two presenting threads may read the process clock in one order and take
|
||||
// the lock in the other; a reused thread id restarts its thread clock.
|
||||
FrameCpuSampler sampler;
|
||||
FrameCpuSample sample;
|
||||
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
|
||||
ASSERT_TRUE(sampler.Sample(1, 4'000'000, 45'000'000, &sample));
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 0.0);
|
||||
EXPECT_FALSE(sample.has_thread);
|
||||
|
||||
// The later process reading stays the baseline.
|
||||
ASSERT_TRUE(sampler.Sample(1, 6'000'000, 60'000'000, &sample));
|
||||
EXPECT_DOUBLE_EQ(sample.process_ms, 10.0);
|
||||
EXPECT_TRUE(sample.has_thread);
|
||||
EXPECT_DOUBLE_EQ(sample.thread_ms, 2.0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
@@ -312,8 +314,10 @@ TEST(CommandLineTest, UsageContainsEverySupportedOption) {
|
||||
EXPECT_EQ(usage.find("--login"), std::string::npos);
|
||||
EXPECT_EQ(usage.find("--import-cookie"), std::string::npos);
|
||||
EXPECT_NE(usage.find("native sign-in flow"), std::string::npos);
|
||||
EXPECT_NE(usage.find("verified managed x86_64 Roblox payload"),
|
||||
std::string::npos);
|
||||
EXPECT_NE(
|
||||
usage.find("verified managed " +
|
||||
std::string(mocktail::compat::kGuestAbi) + " Roblox payload"),
|
||||
std::string::npos);
|
||||
EXPECT_EQ(usage.find("Normal startup uses rbx_bin/libroblox.so"),
|
||||
std::string::npos);
|
||||
EXPECT_EQ(usage.find("--launch-request-json"), std::string::npos);
|
||||
|
||||
@@ -176,8 +176,10 @@ grep -Fq './scripts/build_flatpak.sh' <<<"${dry_run}" ||
|
||||
Fail 'make flatpak does not invoke the guarded Flatpak builder'
|
||||
grep -Fq -- '--manifest packaging/flatpak/io.github.CoderDayton.nightcap.json' \
|
||||
<<<"${dry_run}" || Fail 'make flatpak lost the canonical manifest'
|
||||
grep -Fq -- '--jobs 4' <<<"${dry_run}" ||
|
||||
grep -Fq -- '--jobs 2' <<<"${dry_run}" ||
|
||||
Fail 'make flatpak does not cap local build parallelism'
|
||||
grep -Fq 'JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}"' "${BUILD_HELPER}" ||
|
||||
Fail 'Flatpak helper does not cap local build parallelism'
|
||||
|
||||
mkdir -p -- "${TEMP_DIR}/bin"
|
||||
cat >"${TEMP_DIR}/bin/uname" <<'EOF'
|
||||
|
||||
+37
-19
@@ -13,6 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "compat/build_profile.h"
|
||||
#include "compat/guest_abi.h"
|
||||
#include "compat/host_abi_profile.h"
|
||||
#include "update/apkpure_provider.h"
|
||||
#include "update/compatibility_catalog.h"
|
||||
@@ -521,6 +522,18 @@ TEST(ShippedMetadataTest, ReferenceSidecarDescribesASupportedProfile) {
|
||||
const HostAbiSidecarIdentity identity = ReadHostAbiSidecarIdentity(
|
||||
root / "config/roblox_host_abi_reference.json");
|
||||
ASSERT_TRUE(identity) << identity.error;
|
||||
std::string sidecar_abi = "x86_64";
|
||||
for (const auto& entry :
|
||||
nlohmann::json::parse(
|
||||
ReadFile(root / "config/roblox_compatibility.json"))
|
||||
.at("profiles")) {
|
||||
if (entry.value("elf_build_id", "") == identity.elf_build_id) {
|
||||
sidecar_abi = entry.value("abi", "x86_64");
|
||||
}
|
||||
}
|
||||
if (sidecar_abi != compat::kGuestAbi) {
|
||||
GTEST_SKIP() << "shipped reference sidecar targets " << sidecar_abi;
|
||||
}
|
||||
|
||||
const auto supported = std::find_if(
|
||||
catalog.profiles.begin(), catalog.profiles.end(),
|
||||
@@ -561,6 +574,11 @@ TEST(ShippedMetadataTest, DefaultPayloadHasABuiltinProfileAndMatchingReference)
|
||||
|
||||
const auto payload = nlohmann::json::parse(
|
||||
ReadFile(root / "config/roblox_payload.json"));
|
||||
if (payload.at("abi").get_ref<const std::string&>() != compat::kGuestAbi) {
|
||||
EXPECT_NE(compat::FindHostAbiProfile(preferred->elf_build_id), nullptr);
|
||||
GTEST_SKIP() << "shipped default payload targets "
|
||||
<< payload.at("abi").get<std::string>();
|
||||
}
|
||||
EXPECT_EQ(payload.at("version_name"), preferred->version_name);
|
||||
EXPECT_EQ(payload.at("version_code"), preferred->version_code);
|
||||
EXPECT_EQ(payload.at("elf_build_id"), preferred->elf_build_id);
|
||||
@@ -646,7 +664,7 @@ TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
|
||||
const std::filesystem::path restaged = temporary.root() / "restaged";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -662,8 +680,8 @@ TEST(PayloadStoreTest, RestagingIdenticalBytesKeepsTheInstalledPayload) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
{"source", "apk-pure-native"},
|
||||
{"imported_at", "2026-09-04T03:49:16Z"},
|
||||
@@ -707,7 +725,7 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -725,8 +743,8 @@ TEST(PayloadStoreTest, StagesAndPromotesVerifiedExactPayload) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -770,7 +788,7 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -788,8 +806,8 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -840,7 +858,7 @@ TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -856,8 +874,8 @@ TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -900,7 +918,7 @@ TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "split-apk");
|
||||
Write(prepared / "assets/content/fixture", "asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -916,8 +934,8 @@ TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) {
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
@@ -961,7 +979,7 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
|
||||
const std::filesystem::path prepared = temporary.root() / "prepared";
|
||||
Write(prepared / "libroblox.so", "new-native-library");
|
||||
Write(prepared / "sober_apk/base.apk", "new-base-apk");
|
||||
Write(prepared / "sober_apk/split_config.x86_64.apk", "new-split-apk");
|
||||
Write(prepared / "sober_apk" / compat::kGuestSplitApkFile, "new-split-apk");
|
||||
Write(prepared / "assets/content/fixture", "new-asset");
|
||||
std::string error;
|
||||
std::size_t asset_count = 0;
|
||||
@@ -975,13 +993,13 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) {
|
||||
{"package", "com.roblox.client"},
|
||||
{"version_name", "2.732.1043"},
|
||||
{"version_code", 2814},
|
||||
{"abi", "x86_64"},
|
||||
{"abi", std::string(compat::kGuestAbi)},
|
||||
{"elf_build_id", kBuildId},
|
||||
{"sha256",
|
||||
{{"libroblox", HashRegularFile(prepared / "libroblox.so")},
|
||||
{"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")},
|
||||
{"x86_64_split_apk",
|
||||
HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}},
|
||||
{std::string(compat::kGuestSplitApkHashKey),
|
||||
HashRegularFile(prepared / "sober_apk" / compat::kGuestSplitApkFile)}}},
|
||||
{"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}},
|
||||
};
|
||||
Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n");
|
||||
|
||||
@@ -164,6 +164,28 @@ TEST(SdlEventConverterTest, ConvertsFocusWithoutOwningTheSdlQueue) {
|
||||
EXPECT_TRUE(focus->focused);
|
||||
}
|
||||
|
||||
TEST(SdlEventConverterTest, PreservesHostWheelDirectionAndPrecision) {
|
||||
SDL_Event source{};
|
||||
source.type = SDL_EVENT_MOUSE_WHEEL;
|
||||
source.wheel.mouse_x = 120.0f;
|
||||
source.wheel.mouse_y = 240.0f;
|
||||
platform::PlatformEvent event;
|
||||
auto* non_owning_window = reinterpret_cast<SDL_Window*>(0x1);
|
||||
|
||||
for (const auto direction : {SDL_MOUSEWHEEL_NORMAL, SDL_MOUSEWHEEL_FLIPPED}) {
|
||||
source.wheel.direction = direction;
|
||||
source.wheel.x = 0.25f;
|
||||
source.wheel.y = -1.5f;
|
||||
ASSERT_TRUE(platform::ConvertSdlEvent(non_owning_window, source, &event));
|
||||
const auto* wheel = std::get_if<platform::MouseWheelEvent>(&event.payload);
|
||||
ASSERT_NE(wheel, nullptr);
|
||||
EXPECT_FLOAT_EQ(wheel->delta_x, 0.25f);
|
||||
EXPECT_FLOAT_EQ(wheel->delta_y, -1.5f);
|
||||
EXPECT_FLOAT_EQ(wheel->mouse_x, 120.0f);
|
||||
EXPECT_FLOAT_EQ(wheel->mouse_y, 240.0f);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(SdlEventConverterTest, RejectsInvalidArgumentsAndUnsupportedEvents) {
|
||||
SDL_Event source{};
|
||||
source.type = SDL_EVENT_USER;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#include "runtime/roblox_account_bridge.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
using Json = nlohmann::json;
|
||||
|
||||
// Distinct from the production values so tests prove the bridge uses the
|
||||
// names the engine reports instead of hard-coded strings.
|
||||
constexpr char kProtocol[] = "TestAccountProtocol";
|
||||
constexpr char kAvailableMethod[] = "TestIntegrityAvailable";
|
||||
constexpr char kTokenMethod[] = "TestIntegrityToken";
|
||||
constexpr char kSupportKey[] = "testSupport";
|
||||
constexpr char kTokenKey[] = "testToken";
|
||||
constexpr char kResultKey[] = "testResult";
|
||||
constexpr char kTokenRequest[] =
|
||||
R"({"requestHash":"opaque-hash","timeoutMillis":60000})";
|
||||
|
||||
std::string Copy(JNIEnv* env, jstring value) {
|
||||
if (!value) return {};
|
||||
const char* chars = env->GetStringUTFChars(value, nullptr);
|
||||
const std::string copy = chars ? chars : "";
|
||||
if (chars) env->ReleaseStringUTFChars(value, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
struct Probe {
|
||||
jnivm::VM* vm = nullptr;
|
||||
std::map<std::string, jobject> handlers;
|
||||
std::vector<std::string> native_cleared;
|
||||
int handlers_created = 0;
|
||||
int handlers_cleared = 0;
|
||||
int fail_handler_at = 0;
|
||||
bool null_token_key = false;
|
||||
};
|
||||
|
||||
Probe* g_probe = nullptr;
|
||||
|
||||
void Prepare(void* context) {
|
||||
static_cast<jnivm::VM*>(context)->RestoreFunctions();
|
||||
}
|
||||
|
||||
jstring ProtocolName(JNIEnv* env, jclass) { return env->NewStringUTF(kProtocol); }
|
||||
jstring AvailableMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(kAvailableMethod);
|
||||
}
|
||||
jstring TokenMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(kTokenMethod);
|
||||
}
|
||||
jstring SupportKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(kSupportKey);
|
||||
}
|
||||
jstring TokenKey(JNIEnv* env, jclass) {
|
||||
return g_probe->null_token_key ? nullptr : env->NewStringUTF(kTokenKey);
|
||||
}
|
||||
jstring ResultKey(JNIEnv* env, jclass) { return env->NewStringUTF(kResultKey); }
|
||||
|
||||
void SetHandler(JNIEnv* env, jobject, jstring protocol, jstring method,
|
||||
jobject handler) {
|
||||
EXPECT_EQ(Copy(env, protocol), kProtocol);
|
||||
g_probe->handlers[Copy(env, method)] = handler;
|
||||
}
|
||||
|
||||
void ClearHandler(JNIEnv* env, jobject, jstring protocol, jstring method) {
|
||||
EXPECT_EQ(Copy(env, protocol), kProtocol);
|
||||
g_probe->native_cleared.push_back(Copy(env, method));
|
||||
}
|
||||
|
||||
jobject CreateHandler(void* context, std::shared_ptr<void> target,
|
||||
std::string (*run)(void*, JNIEnv*, jstring)) {
|
||||
auto* probe = static_cast<Probe*>(context);
|
||||
if (++probe->handlers_created == probe->fail_handler_at) return nullptr;
|
||||
return probe->vm->CreateMessageBusRequestHandler(
|
||||
std::move(target), jnivm::MessageBusRequestHandlerCallbacks{run});
|
||||
}
|
||||
|
||||
void ClearHandlerObject(void* context, jobject handler) {
|
||||
auto* probe = static_cast<Probe*>(context);
|
||||
++probe->handlers_cleared;
|
||||
probe->vm->ClearMessageBusRequestHandler(handler);
|
||||
}
|
||||
|
||||
RobloxAccountProtocolSymbols Symbols() {
|
||||
return {ProtocolName, AvailableMethod, TokenMethod, SupportKey,
|
||||
TokenKey, ResultKey, SetHandler, ClearHandler};
|
||||
}
|
||||
|
||||
class RobloxAccountBridgeTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
probe.vm = &vm;
|
||||
g_probe = &probe;
|
||||
env = vm.GetJNIEnv();
|
||||
jclass cls =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
bus = env->AllocObject(cls);
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
void TearDown() override {
|
||||
bridge.reset();
|
||||
env->DeleteLocalRef(bus);
|
||||
g_probe = nullptr;
|
||||
}
|
||||
Status Initialize(RobloxAccountProtocolSymbols symbols = Symbols()) {
|
||||
bridge = std::make_unique<RobloxAccountBridge>(
|
||||
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare}, symbols,
|
||||
RobloxAccountProtocolObjects{bus, &probe, CreateHandler,
|
||||
ClearHandlerObject});
|
||||
return bridge->Initialize();
|
||||
}
|
||||
Json Request(const char* method, const char* payload) {
|
||||
const auto found = probe.handlers.find(method);
|
||||
EXPECT_NE(found, probe.handlers.end()) << method;
|
||||
if (found == probe.handlers.end()) return nullptr;
|
||||
jstring message = env->NewStringUTF(payload);
|
||||
jstring response =
|
||||
vm.DispatchMessageBusRequestHandler(found->second, env, message);
|
||||
env->DeleteLocalRef(message);
|
||||
const std::string body = Copy(env, response);
|
||||
if (response) env->DeleteLocalRef(response);
|
||||
return Json::parse(body, nullptr, false);
|
||||
}
|
||||
|
||||
jnivm::VM vm;
|
||||
Probe probe;
|
||||
JNIEnv* env = nullptr;
|
||||
jobject bus = nullptr;
|
||||
std::unique_ptr<RobloxAccountBridge> bridge;
|
||||
};
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, RegistersBothHandlersUnderEngineNames) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(probe.handlers.size(), 2u);
|
||||
EXPECT_TRUE(probe.handlers.count(kAvailableMethod));
|
||||
EXPECT_TRUE(probe.handlers.count(kTokenMethod));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, ReportsDeviceIntegrityUnsupported) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(Request(kAvailableMethod, "{}"), (Json{{kSupportKey, false}}));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, AnswersTokenRequestWithoutToken) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(Request(kTokenMethod, kTokenRequest),
|
||||
(Json{{kTokenKey, ""}, {kResultKey, "TOKEN_PROVIDER_UNINITIALIZED"}}));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, AnswersMalformedTokenRequestWithoutToken) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_EQ(Request(kTokenMethod, "not json"),
|
||||
(Json{{kTokenKey, ""}, {kResultKey, "TOKEN_PROVIDER_UNINITIALIZED"}}));
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, RejectsIncompletePrerequisites) {
|
||||
RobloxAccountProtocolSymbols symbols = Symbols();
|
||||
symbols.get_token_key = nullptr;
|
||||
|
||||
EXPECT_FALSE(Initialize(symbols).ok());
|
||||
EXPECT_TRUE(probe.handlers.empty());
|
||||
EXPECT_EQ(probe.handlers_created, 0);
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, FailsWithoutRegisteringWhenEngineNameIsNull) {
|
||||
probe.null_token_key = true;
|
||||
|
||||
EXPECT_FALSE(Initialize().ok());
|
||||
EXPECT_TRUE(probe.handlers.empty());
|
||||
EXPECT_EQ(probe.handlers_created, 0);
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, HandlerCreationFailureReleasesEarlierHandler) {
|
||||
probe.fail_handler_at = 2;
|
||||
|
||||
EXPECT_FALSE(Initialize().ok());
|
||||
EXPECT_EQ(probe.handlers_cleared, 1);
|
||||
for (const auto& [method, handler] : probe.handlers) {
|
||||
EXPECT_NE(std::find(probe.native_cleared.begin(),
|
||||
probe.native_cleared.end(), method),
|
||||
probe.native_cleared.end())
|
||||
<< method;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, InitializeTwiceFails) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_FALSE(bridge->Initialize().ok());
|
||||
EXPECT_EQ(probe.handlers_created, 2);
|
||||
}
|
||||
|
||||
TEST_F(RobloxAccountBridgeTest, ShutdownClearsBothHandlersOnce) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
EXPECT_EQ(probe.handlers_cleared, 2);
|
||||
EXPECT_EQ(probe.native_cleared,
|
||||
(std::vector<std::string>{kAvailableMethod, kTokenMethod}));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -50,6 +50,8 @@ struct Probe {
|
||||
int permission_disconnects = 0;
|
||||
int permission_handlers_cleared = 0;
|
||||
std::map<std::string, jobject> permission_handlers;
|
||||
std::map<std::string, jobject> account_handlers;
|
||||
int account_handlers_cleared = 0;
|
||||
std::string permission_response;
|
||||
int browser_bindings = 0;
|
||||
int browser_disconnects = 0;
|
||||
@@ -266,6 +268,45 @@ nlohmann::json QueryMicrophone(Probe* probe) {
|
||||
}
|
||||
|
||||
void SetRequestHandler(JNIEnv*, jobject, jstring, jstring, jobject) {}
|
||||
|
||||
std::string CopyJavaString(JNIEnv* env, jstring value) {
|
||||
const char* chars = value ? env->GetStringUTFChars(value, nullptr) : nullptr;
|
||||
const std::string copy = chars ? chars : "";
|
||||
if (chars) env->ReleaseStringUTFChars(value, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
jstring AccountProtocolName(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("AccountProtocol");
|
||||
}
|
||||
jstring AccountAvailableMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("DeviceIntegrityAvailable");
|
||||
}
|
||||
jstring AccountTokenMethod(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("GetIntegrityToken");
|
||||
}
|
||||
jstring AccountSupportKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("support");
|
||||
}
|
||||
jstring AccountTokenKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("token");
|
||||
}
|
||||
jstring AccountResultKey(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF("result");
|
||||
}
|
||||
void SetAccountHandler(JNIEnv* env, jobject, jstring, jstring method,
|
||||
jobject handler) {
|
||||
g_probe->account_handlers[CopyJavaString(env, method)] = handler;
|
||||
}
|
||||
void ClearAccountHandler(JNIEnv*, jobject, jstring, jstring) {
|
||||
++g_probe->account_handlers_cleared;
|
||||
}
|
||||
|
||||
RobloxAccountProtocolSymbols AccountSymbols() {
|
||||
return {AccountProtocolName, AccountAvailableMethod, AccountTokenMethod,
|
||||
AccountSupportKey, AccountTokenKey, AccountResultKey,
|
||||
SetAccountHandler, ClearAccountHandler};
|
||||
}
|
||||
void ClearNativeRequestHandler(JNIEnv*, jobject, jstring, jstring) {}
|
||||
void PublishRaw(JNIEnv*, jobject, jstring, jstring) {}
|
||||
void BroadcastDataModelFocus(JNIEnv*, jclass, jstring, jstring, jstring) {}
|
||||
@@ -911,6 +952,49 @@ TEST(RobloxExperienceCompositionTest,
|
||||
EXPECT_EQ(probe.browser_disconnects, 4);
|
||||
EXPECT_EQ(probe.browser_releases, 4);
|
||||
EXPECT_EQ(probe.browser_callbacks_cleared, 4);
|
||||
EXPECT_TRUE(probe.account_handlers.empty());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxExperienceCompositionTest,
|
||||
AnswersDeviceIntegrityAsUnsupportedWithPlatformProtocols) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
for (const char* class_name : {
|
||||
"com/roblox/protocols/webview/WebViewProtocol",
|
||||
"com/roblox/universalapp/messagebus/MessageBus",
|
||||
"com/roblox/universalapp/messagebus/Connection",
|
||||
"com/roblox/engine/jni/memstorage/MemStorage",
|
||||
"com/roblox/engine/jni/memstorage/Connection",
|
||||
"com/roblox/engine/jni/memstorage/Callback",
|
||||
}) {
|
||||
vm.RegisterClass(class_name);
|
||||
}
|
||||
RobloxExperienceComposition composition(
|
||||
{vm.GetJavaVM(), &vm, Prepare}, {},
|
||||
{GetWebViewOpenId, GetWebViewHandleWindowCloseId, GetWebViewProtocolName,
|
||||
GetWebViewAvailabilityId, GetWebViewMessageId, InitializeWebViewProtocol,
|
||||
Subscribe, Disconnect, SetRequestHandler, ClearNativeRequestHandler,
|
||||
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
|
||||
GetWebViewCloseId, SignalWebViewJavascriptCallback,
|
||||
UpdateCookieSetHandler},
|
||||
BrowserServiceSymbols(), PermissionsSymbols(), {}, JniFactory(&probe),
|
||||
{}, {}, nullptr, {}, {}, false, false, AccountSymbols());
|
||||
|
||||
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
|
||||
ASSERT_EQ(probe.account_handlers.size(), 2u);
|
||||
JNIEnv* env = vm.GetJNIEnv();
|
||||
jstring request = env->NewStringUTF("{}");
|
||||
jstring response = vm.DispatchMessageBusRequestHandler(
|
||||
probe.account_handlers.at("DeviceIntegrityAvailable"), env, request);
|
||||
EXPECT_EQ(nlohmann::json::parse(CopyJavaString(env, response)),
|
||||
(nlohmann::json{{"support", false}}));
|
||||
env->DeleteLocalRef(request);
|
||||
if (response) env->DeleteLocalRef(response);
|
||||
|
||||
EXPECT_TRUE(composition.Shutdown().ok());
|
||||
EXPECT_EQ(probe.account_handlers_cleared, 2);
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
@@ -1023,7 +1107,7 @@ TEST(RobloxExperienceCompositionTest,
|
||||
class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test {
|
||||
protected:
|
||||
struct Helper {
|
||||
Helper() {
|
||||
explicit Helper(const char* before_ready = "") {
|
||||
char pattern[] = "/tmp/mocktail_surface_close_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
if (created == nullptr) return;
|
||||
@@ -1035,6 +1119,7 @@ import socket, sys
|
||||
sys.stdin.buffer.read()
|
||||
s = socket.socket(fileno=198)
|
||||
s.settimeout(5)
|
||||
)PY" << before_ready << R"PY(
|
||||
s.send(b'MWVE' + bytes([1, 3, 0, 0]) + bytes(4))
|
||||
while True:
|
||||
packet = s.recv(1024 * 1024)
|
||||
@@ -1071,6 +1156,14 @@ while True:
|
||||
++static_cast<ExitProbe*>(context)->calls;
|
||||
}
|
||||
|
||||
static Status Open(RobloxExperienceComposition* composition,
|
||||
const std::shared_ptr<ExitProbe>& probe) {
|
||||
return composition->OpenWebSurface(
|
||||
"https://www.roblox.com/challenge/cdn/hybrid", "webview",
|
||||
RobloxExperienceComposition::WebSurfaceRoute::kWebView,
|
||||
{probe, &RobloxExperienceCompositionWebSurfaceTest::ObserveExit}, {});
|
||||
}
|
||||
|
||||
static std::unique_ptr<RobloxExperienceComposition> MakeComposition(
|
||||
const SecureRobloxCredential* credential = nullptr) {
|
||||
return std::make_unique<RobloxExperienceComposition>(
|
||||
@@ -1132,6 +1225,61 @@ while True:
|
||||
uint64_t process_generation) {
|
||||
composition->HandleWebSurfaceExit(process_generation);
|
||||
}
|
||||
|
||||
static void MarkClosesWhenChallengeSolved(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
composition->web_surface_close_when_challenge_solved_ = true;
|
||||
}
|
||||
|
||||
static bool SolvedChallengeClosePending(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
return composition->solved_challenge_close_deadline_.has_value();
|
||||
}
|
||||
|
||||
static void ExpireSolvedChallengeGrace(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
if (composition->solved_challenge_close_deadline_.has_value()) {
|
||||
composition->solved_challenge_close_deadline_ =
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
|
||||
static std::string ChallengeSuccess() {
|
||||
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"data":{"captchaData":{}},)"
|
||||
R"("feature":"CaptchaSuccess"}},"callbackID":"challenge-1"})";
|
||||
}
|
||||
|
||||
static std::unique_ptr<RobloxExperienceComposition> MakeWebViewComposition(
|
||||
jnivm::VM* vm, Probe* probe) {
|
||||
for (const char* class_name : {
|
||||
"com/roblox/protocols/webview/WebViewProtocol",
|
||||
"com/roblox/universalapp/messagebus/MessageBus",
|
||||
"com/roblox/universalapp/messagebus/Connection",
|
||||
"com/roblox/engine/jni/memstorage/MemStorage",
|
||||
"com/roblox/engine/jni/memstorage/Connection",
|
||||
"com/roblox/engine/jni/memstorage/Callback",
|
||||
}) {
|
||||
vm->RegisterClass(class_name);
|
||||
}
|
||||
return std::make_unique<RobloxExperienceComposition>(
|
||||
JniEnvironmentProvider{vm->GetJavaVM(), vm, Prepare},
|
||||
RobloxExperienceMessageBusSymbols{},
|
||||
RobloxWebViewMessageBusSymbols{
|
||||
GetWebViewOpenId, GetWebViewHandleWindowCloseId,
|
||||
GetWebViewProtocolName, GetWebViewAvailabilityId,
|
||||
GetWebViewMessageId, InitializeWebViewProtocol, Subscribe,
|
||||
Disconnect, SetRequestHandler, ClearNativeRequestHandler,
|
||||
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
|
||||
GetWebViewCloseId, SignalWebViewJavascriptCallback,
|
||||
UpdateCookieSetHandler},
|
||||
BrowserServiceSymbols(), PermissionsSymbols(),
|
||||
RobloxGameSessionSymbols{}, JniFactory(probe),
|
||||
RobloxFreshLaunchPresentBoundary{});
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
@@ -1278,6 +1426,164 @@ TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
EXPECT_EQ(probe->calls, 1);
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
WaitsForSlowHelperStartupBeforeRejectingSurface) {
|
||||
Helper helper("import time\ntime.sleep(3.5)\n");
|
||||
ASSERT_FALSE(helper.path.empty());
|
||||
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
|
||||
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
|
||||
auto composition = MakeComposition(&credential);
|
||||
auto probe = std::make_shared<ExitProbe>();
|
||||
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = Open(composition.get(), probe);
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
|
||||
|
||||
EXPECT_TRUE(status.ok()) << status.message();
|
||||
const std::string marker = "Roblox web surface launched in ";
|
||||
const size_t at = log.find(marker);
|
||||
ASSERT_NE(at, std::string::npos) << log;
|
||||
EXPECT_GE(std::stol(log.substr(at + marker.size())), 3500) << log;
|
||||
EXPECT_TRUE(Close(composition.get()).ok());
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ReportsElapsedStartupTimeWhenHelperNeverBecomesReady) {
|
||||
Helper helper("sys.exit(0)\n");
|
||||
ASSERT_FALSE(helper.path.empty());
|
||||
ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0);
|
||||
SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"};
|
||||
auto composition = MakeComposition(&credential);
|
||||
auto probe = std::make_shared<ExitProbe>();
|
||||
|
||||
const Status status = Open(composition.get(), probe);
|
||||
unsetenv("MOCKTAIL_WEBVIEW_HELPER");
|
||||
|
||||
ASSERT_FALSE(status.ok());
|
||||
EXPECT_NE(status.message().find("did not become ready"), std::string::npos)
|
||||
<< status.message();
|
||||
EXPECT_NE(status.message().find(" ms of 5000 ms"), std::string::npos)
|
||||
<< status.message();
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ForwardsSolvedChallengeAndLeavesClosingToRoblox) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
const std::string success = ChallengeSuccess();
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid,
|
||||
success})
|
||||
.ok());
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, success);
|
||||
EXPECT_TRUE(SolvedChallengeClosePending(composition.get()));
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
ASSERT_TRUE(Close(composition.get()).ok());
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
ExpireSolvedChallengeGrace(composition.get());
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ClosesSolvedChallengeWhenRobloxLeavesItOpen) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kExecuteRoblox,
|
||||
ChallengeSuccess()})
|
||||
.ok());
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
ExpireSolvedChallengeGrace(composition.get());
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = composition->DrainPlatformEvents();
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
ASSERT_TRUE(status.ok()) << status.message();
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_NE(log.find("closing solved challenge window"), std::string::npos)
|
||||
<< log;
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ClosesSolvedCaptchaWindowAfterForwardingSuccess) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
MarkClosesWhenChallengeSolved(composition.get());
|
||||
|
||||
const std::string success = ChallengeSuccess();
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = RouteCurrentEvent(
|
||||
composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid, success});
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
ASSERT_TRUE(status.ok()) << status.message();
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, success);
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
EXPECT_NE(log.find("closing solved captcha window"), std::string::npos)
|
||||
<< log;
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
UnsolvedChallengeEventsKeepWindowOpen) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
const std::string shown =
|
||||
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"feature":"CaptchaShown"}},"callbackID":"c"})";
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid,
|
||||
shown})
|
||||
.ok());
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, shown);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
EXPECT_TRUE(Close(composition.get()).ok());
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
namespace {} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -325,7 +325,7 @@ TEST_F(RobloxInputNativeAdapterTest, CallsExactExportedJniSignatures) {
|
||||
(std::vector<double>{90.0, 190.0, 4.0, -2.0}));
|
||||
EXPECT_EQ(probe_.calls[1].name, "mouse-button");
|
||||
EXPECT_EQ(probe_.calls[1].arguments,
|
||||
(std::vector<double>{100.0, 200.0, JNI_TRUE, 3.0}));
|
||||
(std::vector<double>{100.0, 200.0, JNI_TRUE, 2.0}));
|
||||
EXPECT_EQ(probe_.calls[2].name, "touch");
|
||||
EXPECT_EQ(probe_.calls[2].arguments,
|
||||
(std::vector<double>{0.0, 320.0, 360.0, 0.0, 1280.0, 720.0}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
®ion);
|
||||
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,
|
||||
®ion);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"}}})",
|
||||
|
||||
Reference in new issue
Block a user